Compare commits

...
Author SHA1 Message Date
dfa9bd57a1 Update src/pretix/base/models/event.py
Co-authored-by: Martin Gross <gross@rami.io>
2026-04-27 15:00:24 +02:00
Raphael Michel c651a3d5ed Turn attendee emails on by default for new events (Z#23213656)
I think the thing that makes me most unhappy is that *most* organizers will
probably want to turn off mail_send_order_paid_attendee when they set
ticket_download_pending and I don't think organizers will remember that, but
it also seems complex and weird to create an automatism for it?
2025-11-10 11:27:22 +01:00
Richard SchreiberandGitHub 0cc6439748 Fix API-docs missing item_program_times (#5594) 2025-11-07 13:04:04 +01:00
Richard SchreiberandGitHub ad53c48d0f Fix price-column in item export for free variations 2025-11-07 11:57:06 +01:00
luelistaandGitHub 59a5c11ef6 Rename migration (#5592) 2025-11-07 11:40:27 +01:00
luelistaandGitHub 1cb2d443f9 Validation of user email addresses (#5434)
* Validation of user email addresses
* Improve email and password change forms
2025-11-07 11:17:34 +01:00
Raphael Michel a0dbf6c5db Force Django upgrade (CVE-2025-64459) 2025-11-06 15:04:03 +01:00
fd9d03786b Add program times for items (Z#23178639)
* Add program times for items

* Fix frontend date validation

* Add ical data for program times [wip]

* Improve ical data for program times

* Remove duplicate code and add comments

* Adjust migration

* Remove program times form for event series

* Add pdf placeholder [wip]

* Improve explanation text with suggestion

Co-authored-by: Raphael Michel <michel@pretix.eu>

* Fix import sorting

* Improve ical generation

* Improve ical entry description

* Fix migration

* Add copyability for program times fot items and events

* Update migration

* Add API endpoints/functions, fix isort

* Improve variable name

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

* Remove todo comment

* Add documentation, Change endpoint name

* Change related name

* Remove unnecessary code block

* Add program times to item API

* Fix imports

* Add log text

* Use daterange helper

* Add and update API tests

* Add another API test

* Add program times to cloning tests

* Update query count because of program times query

* Invalidate cached tickets on program time changes

* Reduce invalidation calls

* Update migration after rebase

* Apply improvements to invalidation from review

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

* remove unneccessary attr=item param

* remove unnecessary kwargs for formset_factory

* fix local var name being overwritten in for-loop

* fix empty formset being saved

* Use subevent if available

* make code less verbose

* remove double event-label in ical desc

* fix unnecessary var re-assign

* fix ev vs p.subevent

---------

Co-authored-by: Raphael Michel <michel@pretix.eu>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-11-06 12:24:47 +01:00
Richard SchreiberandGitHub 7041d40972 Invoice-PDF: split line.description into multiple rows so table can pagebreak (#5545) 2025-11-06 09:44:23 +01:00
Richard SchreiberandGitHub 0b46982e6d Allow blocked seats to be booked in backend (#5585) 2025-11-06 08:02:42 +01:00
Yasunobu YesNo KawaguchiandRaphael Michel 29906c6288 Translations: Update Japanese
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-esandRaphael Michel 3380bd3e82 Translations: Update Spanish
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-esandRaphael Michel 6ae8e7cbb6 Translations: Update French
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-esandRaphael Michel 23c2d9266e Translations: Update Spanish
Currently translated at 99.9% (6130 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-esandRaphael Michel ba155faaa3 Translations: Update French
Currently translated at 99.9% (6131 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
Núria MasclansandRaphael Michel fd177fa89f Translations: Update Catalan
Currently translated at 78.7% (200 of 254 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-esandRaphael Michel 0b051c1400 Translations: Update Spanish
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-esandRaphael Michel af8d0f0b65 Translations: Update French
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
Raphael MichelandRaphael Michel 1b7bba195c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
Raphael MichelandRaphael Michel f056f77dc0 Translations: Update German
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
Richard SchreiberandGitHub ee4e7f618f Sort answers according to questions order
* Sort answers according to questions (Z#23212280)

* Undo ordering in Meta-class

* filter and order answers only on invoice
2025-11-04 11:33:16 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cd450f1780 Bump @babel/core from 7.28.4 to 7.28.5 in /src/pretix/static/npm_dir (#5579)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.28.4 to 7.28.5.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.5/packages/babel-core)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 11:26:29 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fc876978b2 Bump @rollup/plugin-babel in /src/pretix/static/npm_dir (#5581)
Bumps [@rollup/plugin-babel](https://github.com/rollup/plugins/tree/HEAD/packages/babel) from 6.0.4 to 6.1.0.
- [Changelog](https://github.com/rollup/plugins/blob/master/packages/babel/CHANGELOG.md)
- [Commits](https://github.com/rollup/plugins/commits/url-v6.1.0/packages/babel)

---
updated-dependencies:
- dependency-name: "@rollup/plugin-babel"
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 11:26:22 +01:00
dependabot[bot]andRaphael Michel d8efaa47f7 Update css-inline requirement from ==0.17.* to ==0.18.*
Updates the requirements on [css-inline](https://github.com/Stranger6667/css-inline) to permit the latest version.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/v0.17.0...v0.18.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-04 11:25:45 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f0c3514588 Bump @babel/preset-env in /src/pretix/static/npm_dir (#5580)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.28.3 to 7.28.5.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.5/packages/babel-preset-env)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 17:40:47 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e1ad4d9dba Bump @rollup/plugin-node-resolve in /src/pretix/static/npm_dir (#5578)
Bumps [@rollup/plugin-node-resolve](https://github.com/rollup/plugins/tree/HEAD/packages/node-resolve) from 16.0.1 to 16.0.3.
- [Changelog](https://github.com/rollup/plugins/blob/master/packages/node-resolve/CHANGELOG.md)
- [Commits](https://github.com/rollup/plugins/commits/node-resolve-v16.0.3/packages/node-resolve)

---
updated-dependencies:
- dependency-name: "@rollup/plugin-node-resolve"
  dependency-version: 16.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 17:40:15 +01:00
Raphael Michel 3ab587883e Translations: Add words to spellcheck wordlist 2025-11-03 17:40:04 +01:00
Raphael MichelandGitHub b02e1a1515 Gift card payment: Clean-up some code (#5574)
* Remove apparently unused code

* Move templates that do not belong in pretixcontrol
2025-11-03 17:38:56 +01:00
Raphael Michel 41780add40 Gift cards: Remove nested form tags 2025-10-30 18:03:47 +01:00
Raphael Michel b07a61e4f1 Remove visible rounding mode 2025-10-30 17:54:06 +01:00
Raphael MichelandGitHub dead2a9bed Bank transfer: Reorder fields for pending bank details (#5562)
* Reorder fields for pending bank transfer

* Remove unnecessary endif statement in pending.html

* Fix indentation in pending.html template
2025-10-30 16:40:39 +01:00
Raphael Michel 94389c3913 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2025-10-30 11:55:47 +01:00
3e972eddbf Allow to round taxes on order-level (#5019)
* Allow to round taxes on order-level

* Rename get_cart_total

* Persist rounding mode with order

* Add general docs

* Order creation API

* Update fee algorithm

* Rounding on payment method change

* Round when splitting order

* Fix failing tests

* Add settings page

* Add tests

* Replace algorithm

* Add test case for currency rounding

* Improve order change

* Update flowchart

* Update discount logic (more hypothetical, we don't store rounding on cart positions atm)

* Rename internal method

* Fix typo

* Update help text

* Apply suggestions from code review

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

* Order rounding refactor (#5571)

* Add RoundingCorrectionMixin providing before-rounding-values as properties

* Use gross_price_before_rounding in more places

* Update doc/development/algorithms/pricing.rst

Co-authored-by: Martin Gross <gross@rami.io>

* Allow to override on perform_order

* Rebase migration

* Fix event cancellation

---------

Co-authored-by: luelista <weller@rami.io>
Co-authored-by: Martin Gross <gross@rami.io>
2025-10-30 11:49:31 +01:00
dependabot[bot]andRaphael Michel cdeb1e86bd Update sentry-sdk requirement from ==2.42.* to ==2.43.*
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.42.0...2.43.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-30 11:33:10 +01:00
Raphael MichelandRaphael Michel 9a69b76880 API: Expose history of check-ins (Z#23206049) 2025-10-30 10:45:01 +01:00
Richard SchreiberandGitHub 7d5df2b69e Fix required label for multi-checkbox form-groups (#5568) 2025-10-30 10:44:17 +01:00
Raphael Michel d203eee5ab Bump version to 2025.10.0.dev0 2025-10-30 09:53:25 +01:00
150 changed files with 66408 additions and 57411 deletions
+91
View File
@@ -421,3 +421,94 @@ Annulment of a check-in
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested nonce does not exist. :statuscode 404: The requested nonce does not exist.
Check-in history
----------------
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the check-in
successful boolean Whether the check-in was successful
error_reason string Category of reason why the check-in was unsuccessful. Currently
``"canceled"``, ``"invalid"``, ``"unpaid"`` ``"product"``,
``"rules"``, ``"revoked"``, ``"incomplete"``, ``"already_redeemed"``,
``"ambiguous"``, ``"error"``, ``"blocked"``, ``"unapproved"``,
``"invalid_time"``, ``"annulled"`` or ``null``
error_explanation string Additional, human-readable reason for the check-in to be unsuccessful (or ``null``)
position integer Internal ID of the order position (or ``null`` for unknown scans)
datetime datetime Logical time when the check-in happened
created datetime Time when the check-in appeared on the server
list integer Internal ID of the check-in list
auto_checked_in boolean Whether the check-in was performed by the system automatically
gate integer Internal ID of the gate (or ``null``)
device integer Internal ID of the device (or ``null``)
device_id integer Organizer-internal ID of the device (or ``null``)
type string Type of check-in, currently ``"entry"`` or ``"exit"``
===================================== ========================== =======================================================
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkins/
Returns a list of all check-in events within a given event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/checkins/ 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,
"successful": true,
"error_reason": null,
"error_explanation": null,
"position": 1234,
"datetime": "2017-12-25T12:45:23Z",
"created": "2017-12-25T12:45:23Z",
"list": 2,
"auto_checked_in": false,
"gate": null,
"device": null,
"device_id": null,
"type": "entry",
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:query datetime created_since: Only return check-ins that have been created since the given date (inclusive).
:query datetime created_before: Only return check-ins that have been created before the given date (exclusive).
:query datetime datetime_since: Only return check-ins that have happened since the given date (inclusive).
:query datetime datetime_before: Only return check-ins that have happened before the given date (exclusive).
:query boolean successful: Only return check-ins that have (not) been successful.
:query boolean error_reason: Only return check-ins with a specific error reason.
:query integer list: Only return check-ins from a specific list.
:query string type: Only return check-ins of a specific type.
:query integer gate: Only return check-ins from a specific gate.
:query integer device: Only return check-ins from a specific device.
:query boolean auto_checked_in: Only return check-ins that are (not) auto-checked in.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``created``,
and ``id``.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
+1
View File
@@ -19,6 +19,7 @@ at :ref:`plugin-docs`.
item_bundles item_bundles
item_add-ons item_add-ons
item_meta_properties item_meta_properties
item_program_times
questions questions
question_options question_options
quotas quotas
+222
View File
@@ -0,0 +1,222 @@
Item program times
==================
Resource description
--------------------
Program times for products (items) that can be set in addition to event times, e.g. to display seperate schedules within an event.
The program times resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the program time
start datetime The start date time for this program time slot.
end datetime The end date time for this program time slot.
===================================== ========================== =======================================================
.. versionchanged:: TODO
The resource has been added.
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/
Returns a list of all program times for a given item.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/items/11/program_times/ 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": 3,
"next": null,
"previous": null,
"results": [
{
"id": 2,
"start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z"
},
{
"id": 3,
"start": "2025-08-12T22:00:00Z",
"end": "2025-08-13T22:00:00Z"
},
{
"id": 14,
"start": "2025-08-15T22:00:00Z",
"end": "2025-08-17T22:00:00Z"
}
]
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param item: The ``id`` field of the item to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/(id)/
Returns information on one program time, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ 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
{
"id": 1,
"start": "2025-08-15T22:00:00Z",
"end": "2025-10-27T23:00:00Z"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param item: The ``id`` field of the item to fetch
:param id: The ``id`` field of the program time to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/
Creates a new program time
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 17,
"start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z"
}
:param organizer: The ``slug`` field of the organizer of the event/item to create a program time for
:param event: The ``slug`` field of the event to create a program time for
:param item: The ``id`` field of the item to create a program time for
:statuscode 201: no error
:statuscode 400: The program time could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/(id)/
Update a program time. 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/events/sampleconf/items/1/program_times/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"start": "2025-08-14T10:00:00Z"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"start": "2025-08-14T10:00:00Z",
"end": "2025-08-15T12:00:00Z"
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the item to modify
:param id: The ``id`` field of the program time to modify
:statuscode 200: no error
:statuscode 400: The program time could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/program_times/(id)/
Delete a program time.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/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 to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the item to modify
:param id: The ``id`` field of the program time to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
+33 -10
View File
@@ -139,6 +139,9 @@ has_variations boolean Shows whether
variations list of objects A list with one object for each variation of this item. variations list of objects A list with one object for each variation of this item.
Can be empty. Only writable during creation, Can be empty. Only writable during creation,
use separate endpoint to modify this later. use separate endpoint to modify this later.
program_times list of objects A list with one object for each program time of this item.
Can be empty. Only writable during creation,
use separate endpoint to modify this later.
├ id integer Internal ID of the variation ├ id integer Internal ID of the variation
├ value multi-lingual string The "name" of the variation ├ value multi-lingual string The "name" of the variation
├ default_price money (string) The price set directly for this variation or ``null`` ├ default_price money (string) The price set directly for this variation or ``null``
@@ -225,6 +228,10 @@ meta_data object Values set fo
The ``hidden_if_item_available_mode`` attributes has been added. The ``hidden_if_item_available_mode`` attributes has been added.
.. versionchanged:: 2025.9
The ``program_times`` attribute has been added.
Notes Notes
----- -----
@@ -232,9 +239,9 @@ Please note that an item either always has variations or never has. Once created
change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least
one variation. one variation.
Also note that ``variations``, ``bundles``, and ``addons`` are only supported on ``POST``. To update/delete variations, Also note that ``variations``, ``bundles``, ``addons`` and ``program_times`` are only supported on ``POST``. To update/delete variations,
bundles, and add-ons please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT`` bundles, add-ons and program times please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT``
with nested ``variations``, ``bundles`` and/or ``addons``. with nested ``variations``, ``bundles``, ``addons`` and/or ``program_times``.
Endpoints Endpoints
--------- ---------
@@ -373,7 +380,8 @@ Endpoints
} }
], ],
"addons": [], "addons": [],
"bundles": [] "bundles": [],
"program_times": []
} }
] ]
} }
@@ -525,7 +533,8 @@ Endpoints
} }
], ],
"addons": [], "addons": [],
"bundles": [] "bundles": [],
"program_times": []
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -653,7 +662,13 @@ Endpoints
} }
], ],
"addons": [], "addons": [],
"bundles": [] "bundles": [],
"program_times": [
{
"start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z"
}
]
} }
**Example response**: **Example response**:
@@ -773,7 +788,13 @@ Endpoints
} }
], ],
"addons": [], "addons": [],
"bundles": [] "bundles": [],
"program_times": [
{
"start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z"
}
]
} }
:param organizer: The ``slug`` field of the organizer of the event to create an item for :param organizer: The ``slug`` field of the organizer of the event to create an item for
@@ -789,8 +810,9 @@ Endpoints
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change. want to change.
You can change all fields of the resource except the ``has_variations``, ``variations`` and the ``addon`` field. If You can change all fields of the resource except the ``has_variations``, ``variations``, ``addon`` and the
you need to update/delete variations or add-ons please use the nested dedicated endpoints. ``program_times`` field. If you need to update/delete variations, add-ons or program times, please use the nested
dedicated endpoints.
**Example request**: **Example request**:
@@ -924,7 +946,8 @@ Endpoints
} }
], ],
"addons": [], "addons": [],
"bundles": [] "bundles": [],
"program_times": []
} }
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify
+8
View File
@@ -41,6 +41,7 @@ expires datetime The order will
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order
total money (string) Total value of this order total money (string) Total value of this order
tax_rounding_mode string Tax rounding mode, see :ref:`algorithms-rounding`
comment string Internal comment on this order comment string Internal comment on this order
api_meta object Meta data for that order. Only available through API, no guarantees api_meta object Meta data for that order. Only available through API, no guarantees
on the content structure. You can use this to save references to your system. on the content structure. You can use this to save references to your system.
@@ -151,6 +152,10 @@ plugin_data object Additional data
The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added. The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added.
.. versionchanged:: 2025.10
The ``tax_rounding_mode`` attribute has been added.
.. _order-position-resource: .. _order-position-resource:
Order position resource Order position resource
@@ -358,6 +363,7 @@ List of all orders
"payment_provider": "banktransfer", "payment_provider": "banktransfer",
"fees": [], "fees": [],
"total": "23.00", "total": "23.00",
"tax_rounding_mode": "line",
"comment": "", "comment": "",
"custom_followup_at": null, "custom_followup_at": null,
"checkin_attention": false, "checkin_attention": false,
@@ -602,6 +608,7 @@ Fetching individual orders
"payment_provider": "banktransfer", "payment_provider": "banktransfer",
"fees": [], "fees": [],
"total": "23.00", "total": "23.00",
"tax_rounding_mode": "line",
"comment": "", "comment": "",
"api_meta": {}, "api_meta": {},
"custom_followup_at": null, "custom_followup_at": null,
@@ -1011,6 +1018,7 @@ Creating orders
provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no* provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no*
charge will be created), this is just informative in case you *handled the payment already*. charge will be created), this is just informative in case you *handled the payment already*.
* ``payment_date`` (optional) Date and time of the completion of the payment. * ``payment_date`` (optional) Date and time of the completion of the payment.
* ``tax_rounding_mode`` (optional)
* ``comment`` (optional) * ``comment`` (optional)
* ``custom_followup_at`` (optional) * ``custom_followup_at`` (optional)
* ``checkin_attention`` (optional) * ``checkin_attention`` (optional)
+121
View File
@@ -178,3 +178,124 @@ Flowchart
--------- ---------
.. image:: /images/cart_pricing.png .. image:: /images/cart_pricing.png
.. _`algorithms-rounding`:
Rounding of taxes
-----------------
pretix internally always stores taxes on a per-line level, like this:
========== ========== =========== ======= =============
Product Tax rate Net price Tax Gross price
========== ========== =========== ======= =============
Ticket A 19 % 84.03 15.97 100.00
Ticket B 19 % 84.03 15.97 100.00
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.15 79.85 500.00
========== ========== =========== ======= =============
Whether the net price is computed from the gross price or vice versa is configured on the tax rule and may differ for every line.
The line-based computation has a few significant advantages:
- We can report both net and gross prices for every individual ticket.
- We can report both net and gross prices for every filter imaginable, such as the gross sum of all sales of Ticket A
or the net sum of all sales for a specific date in an event series. All numbers will be exact.
- When splitting the order into two, both net price and gross price are split without any changes in rounding.
The main disadvantage is that the tax looks "wrong" when computed from the sum. Taking the sum of net prices (420.15)
and multiplying it with the tax rate (19%) yields a tax amount of 79.83 (instead of 79.85) and a gross sum of 499.98
(instead of 499.98). This becomes a problem when juristictions, data formats, or external systems expect this calculation
to work on the level of the entire order. A prominent example is the EN 16931 standard for e-invoicing that
does not allow the computation as created by pretix.
However, calculating the tax rate from the net total has significant disadvantages:
- It is impossible to guarantee a stable gross price this way, i.e. if you advertise a price of €100 per ticket to
consumers, they will be confused when they only need to pay €499.98 for 5 tickets.
- Some prices are impossible, e.g. you cannot sell a ticket for a gross price of €99.99 at a 19% tax rate, since there
is no two-decimal net price that would be computed to a gross price of €99.99.
- When splitting an order into two, the combined of the new orders is not guaranteed to be the same as the total of the
original order. Therefore, additional payments or refunds of very small amounts might be necessary.
To allow organizers to make their own choices on this matter, pretix provides the following options:
Compute taxes for every line individually
"""""""""""""""""""""""""""""""""""""""""
Algorithm identifier: ``line``
This is our original algorithm where the tax value is rounded for every line individually.
**This is our current default algorithm and we recommend it whenever you do not have different requirements** (see below).
For the example above:
========== ========== =========== ======= =============
Product Tax rate Net price Tax Gross price
========== ========== =========== ======= =============
Ticket A 19 % 84.03 15.97 100.00
Ticket B 19 % 84.03 15.97 100.00
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.15 79.85 500.00
========== ========== =========== ======= =============
Compute taxes based on net total
""""""""""""""""""""""""""""""""
Algorithm identifier: ``sum_by_net``
In this algorithm, the tax value and gross total are computed from the sum of the net prices. To accomplish this within
our data model, the gross price and tax of some of the tickets will be changed by the minimum currency unit (e.g. €0.01).
The net price of the tickets always stay the same.
**This is the algorithm intended by EN 16931 invoices and our recommendation to use for e-invoicing when (primarily) business customers are involved.**
The main downside is that it might be confusing when selling to consumers, since the amounts to be paid change in unexpected ways.
For the example above, the customer expects to pay 5 times 100.00, but they are are in fact charged 499.98:
========== ========== =========== ============================== ==============================
Product Tax rate Net price Tax Gross price
========== ========== =========== ============================== ==============================
Ticket A 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding)
Ticket B 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding)
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.15 78.83 499.98
========== ========== =========== ============================== ==============================
Compute taxes based on net total with stable gross prices
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""
Algorithm identifier: ``sum_by_net_keep_gross``
In this algorithm, the tax value and gross total are computed from the sum of the net prices. However, the net prices
of some of the tickets will be changed automatically by the minimum currency unit (e.g. €0.01) such that the resulting
gross prices stay the same.
**This is less confusing to consumers and the end result is still compliant to EN 16931, so we recommend this for e-invoicing when (primarily) consumers are involved.**
The main downside is that it might be confusing when selling to business customers, since the prices of the identical tickets appear to be different.
Full computation for the example above:
========== ========== ============================= ============================== =============
Product Tax rate Net price Tax Gross price
========== ========== ============================= ============================== =============
Ticket A 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
Ticket B 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.17 79.83 500.00
========== ========== ============================= ============================== =============
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 49 KiB

+1
View File
@@ -23,6 +23,7 @@ partition "For every cart position" {
--> "Store as line_price (gross), tax_rate" --> "Store as line_price (gross), tax_rate"
} }
--> "Apply discount engine" --> "Apply discount engine"
--> "Apply tax rounding"
--> "Store as price (gross)" --> "Store as price (gross)"
@enduml @enduml
+3 -3
View File
@@ -33,9 +33,9 @@ dependencies = [
"celery==5.5.*", "celery==5.5.*",
"chardet==5.2.*", "chardet==5.2.*",
"cryptography>=44.0.0", "cryptography>=44.0.0",
"css-inline==0.17.*", "css-inline==0.18.*",
"defusedcsv>=1.1.0", "defusedcsv>=1.1.0",
"Django[argon2]==4.2.*,>=4.2.24", "Django[argon2]==4.2.*,>=4.2.26",
"django-bootstrap3==25.2", "django-bootstrap3==25.2",
"django-compressor==4.5.1", "django-compressor==4.5.1",
"django-countries==7.6.*", "django-countries==7.6.*",
@@ -91,7 +91,7 @@ dependencies = [
"redis==6.4.*", "redis==6.4.*",
"reportlab==4.4.*", "reportlab==4.4.*",
"requests==2.32.*", "requests==2.32.*",
"sentry-sdk==2.42.*", "sentry-sdk==2.43.*",
"sepaxml==2.7.*", "sepaxml==2.7.*",
"stripe==7.9.*", "stripe==7.9.*",
"text-unidecode==1.*", "text-unidecode==1.*",
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2025.9.0" __version__ = "2025.10.0.dev0"
+1
View File
@@ -829,6 +829,7 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_eu_currencies', 'invoice_eu_currencies',
'invoice_logo_image', 'invoice_logo_image',
'invoice_renderer_highlight_order_code', 'invoice_renderer_highlight_order_code',
'tax_rounding',
'cancel_allow_user', 'cancel_allow_user',
'cancel_allow_user_until', 'cancel_allow_user_until',
'cancel_allow_user_unpaid_keep', 'cancel_allow_user_unpaid_keep',
+49 -6
View File
@@ -47,8 +47,9 @@ from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers.fields import UploadedFileField from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import ( from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemProgramTime,
ItemVariationMetaValue, Question, QuestionOption, Quota, SalesChannel, ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SalesChannel,
) )
@@ -187,6 +188,12 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
'position', 'price_included', 'multi_allowed') 'position', 'price_included', 'multi_allowed')
class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('start', 'end')
class ItemBundleSerializer(serializers.ModelSerializer): class ItemBundleSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ItemBundle model = ItemBundle
@@ -212,6 +219,31 @@ class ItemBundleSerializer(serializers.ModelSerializer):
return data return data
class ItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('id', 'start', 'end')
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)
start = full_data.get('start')
if not start:
raise ValidationError(_("The program start must not be empty."))
end = full_data.get('end')
if not end:
raise ValidationError(_("The program end must not be empty."))
if start > end:
raise ValidationError(_("The program end must not be before the program start."))
return data
class ItemAddOnSerializer(serializers.ModelSerializer): class ItemAddOnSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ItemAddOn model = ItemAddOn
@@ -250,6 +282,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
addons = InlineItemAddOnSerializer(many=True, required=False) addons = InlineItemAddOnSerializer(many=True, required=False)
bundles = InlineItemBundleSerializer(many=True, required=False) bundles = InlineItemBundleSerializer(many=True, required=False)
variations = InlineItemVariationSerializer(many=True, required=False) variations = InlineItemVariationSerializer(many=True, required=False)
program_times = InlineItemProgramTimeSerializer(many=True, required=False)
tax_rate = ItemTaxRateField(source='*', read_only=True) tax_rate = ItemTaxRateField(source='*', read_only=True)
meta_data = MetaDataField(required=False, source='*') meta_data = MetaDataField(required=False, source='*')
picture = UploadedFileField(required=False, allow_null=True, allowed_types=( picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
@@ -271,7 +304,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
'available_from', 'available_from_mode', 'available_until', 'available_until_mode', 'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations', 'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', 'addons', 'bundles', 'program_times', 'original_price', 'require_approval', 'generate_tickets',
'show_quota_left', 'hidden_if_available', 'hidden_if_item_available', 'hidden_if_item_available_mode', 'allow_waitinglist', 'show_quota_left', 'hidden_if_available', 'hidden_if_item_available', 'hidden_if_item_available_mode', 'allow_waitinglist',
'issue_giftcard', 'meta_data', 'issue_giftcard', 'meta_data',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type', 'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
@@ -294,9 +327,9 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data): if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data or 'program_times' in data):
raise ValidationError(_('Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use the ' raise ValidationError(_('Updating add-ons, bundles, program times or variations via PATCH/PUT is not '
'dedicated nested endpoint.')) 'supported. Please use the dedicated nested endpoint.'))
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order')) Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
Item.clean_available(data.get('available_from'), data.get('available_until')) Item.clean_available(data.get('available_from'), data.get('available_until'))
@@ -347,6 +380,13 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
ItemAddOn.clean_max_min_count(addon_data.get('max_count', 0), addon_data.get('min_count', 0)) ItemAddOn.clean_max_min_count(addon_data.get('max_count', 0), addon_data.get('min_count', 0))
return value return value
def validate_program_times(self, value):
if not self.instance:
for program_time_data in value:
ItemProgramTime.clean_start_end(self, start=program_time_data.get('start', None),
end=program_time_data.get('end', None))
return value
@cached_property @cached_property
def item_meta_properties(self): def item_meta_properties(self):
return { return {
@@ -364,6 +404,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
variations_data = validated_data.pop('variations') if 'variations' in validated_data else {} variations_data = validated_data.pop('variations') if 'variations' in validated_data else {}
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {} addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {} bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
program_times_data = validated_data.pop('program_times') if 'program_times' in validated_data else {}
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None) picture = validated_data.pop('picture', None)
require_membership_types = validated_data.pop('require_membership_types', []) require_membership_types = validated_data.pop('require_membership_types', [])
@@ -398,6 +439,8 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
ItemAddOn.objects.create(base_item=item, **addon_data) ItemAddOn.objects.create(base_item=item, **addon_data)
for bundle_data in bundles_data: for bundle_data in bundles_data:
ItemBundle.objects.create(base_item=item, **bundle_data) ItemBundle.objects.create(base_item=item, **bundle_data)
for program_time_data in program_times_data:
ItemProgramTime.objects.create(item=item, **program_time_data)
# Meta data # Meta data
if meta_data is not None: if meta_data is not None:
+59 -14
View File
@@ -52,9 +52,10 @@ from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import ( from pretix.base.models import (
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item, CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher, QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
Voucher,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -64,10 +65,13 @@ from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages from pretix.base.services.cart import error_messages
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
from pretix.base.services.pricing import ( from pretix.base.services.pricing import (
apply_discounts, get_line_price, get_listed_price, is_included_for_free, apply_discounts, apply_rounding, get_line_price, get_listed_price,
is_included_for_free,
) )
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, ROUNDING_MODES,
)
from pretix.base.signals import register_ticket_outputs from pretix.base.signals import register_ticket_outputs
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
@@ -325,7 +329,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
return data return data
class CheckinSerializer(I18nAwareModelSerializer): class InlineCheckinSerializer(I18nAwareModelSerializer):
device_id = serializers.SlugRelatedField( device_id = serializers.SlugRelatedField(
source='device', source='device',
slug_field='device_id', slug_field='device_id',
@@ -337,6 +341,21 @@ class CheckinSerializer(I18nAwareModelSerializer):
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'device_id', 'type') fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'device_id', 'type')
class CheckinSerializer(I18nAwareModelSerializer):
device_id = serializers.SlugRelatedField(
source='device',
slug_field='device_id',
read_only=True,
)
class Meta:
model = Checkin
fields = (
'id', 'successful', 'error_reason', 'error_explanation', 'position', 'datetime', 'list', 'created',
'auto_checked_in', 'gate', 'device', 'device_id', 'type'
)
class PrintLogSerializer(serializers.ModelSerializer): class PrintLogSerializer(serializers.ModelSerializer):
device_id = serializers.SlugRelatedField( device_id = serializers.SlugRelatedField(
source='device', source='device',
@@ -560,7 +579,7 @@ class OrderPositionPluginDataField(serializers.Field):
class OrderPositionSerializer(I18nAwareModelSerializer): class OrderPositionSerializer(I18nAwareModelSerializer):
checkins = CheckinSerializer(many=True, read_only=True) checkins = InlineCheckinSerializer(many=True, read_only=True)
print_logs = PrintLogSerializer(many=True, read_only=True) print_logs = PrintLogSerializer(many=True, read_only=True)
answers = AnswerSerializer(many=True) answers = AnswerSerializer(many=True)
downloads = PositionDownloadsField(source='*', read_only=True) downloads = PositionDownloadsField(source='*', read_only=True)
@@ -833,14 +852,15 @@ class OrderSerializer(I18nAwareModelSerializer):
list_serializer_class = OrderListSerializer list_serializer_class = OrderListSerializer
fields = ( fields = (
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date', 'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads', 'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'comment', 'custom_followup_at', 'invoice_address',
'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'positions', 'downloads', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds',
'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', 'plugin_data', 'require_approval', 'sales_channel', 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date',
'plugin_data',
) )
read_only_fields = ( read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date', 'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer', 'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'positions', 'downloads', 'customer',
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date' 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date',
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -1159,6 +1179,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
queryset=SalesChannel.objects.none(), queryset=SalesChannel.objects.none(),
required=False, required=False,
) )
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True) locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -1175,7 +1196,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date', 'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', 'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
'require_approval', 'valid_if_pending', 'expires', 'api_meta') 'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode')
def validate_payment_provider(self, pp): def validate_payment_provider(self, pp):
if pp is None: if pp is None:
@@ -1701,7 +1722,31 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else: else:
f.save() f.save()
order.total += sum([f.value for f in fees]) rounding_mode = validated_data.get("tax_rounding_mode")
if not rounding_mode:
if isinstance(self.context.get("auth"), Device):
# Safety fallback to avoid differences in tax reporting
brand = self.context.get("auth").software_brand or ""
if "pretixPOS" in brand or "pretixKIOSK" in brand:
rounding_mode = "line"
if not rounding_mode:
rounding_mode = self.context["event"].settings.tax_rounding
changed = apply_rounding(
rounding_mode,
self.context["event"].currency,
[*pos_map.values(), *fees]
)
for line in changed:
if isinstance(line, OrderPosition):
line.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(line, OrderFee):
line.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
order.total = sum([c.price for c in pos_map.values()]) + sum([f.value for f in fees])
if simulate: if simulate:
order.fees = fees order.fees = fees
order.positions = pos_map.values() order.positions = pos_map.values()
+2
View File
@@ -92,6 +92,7 @@ event_router.register(r'taxrules', event.TaxRuleViewSet)
event_router.register(r'seats', event.SeatViewSet) event_router.register(r'seats', event.SeatViewSet)
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet) event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
event_router.register(r'checkinlists', checkin.CheckinListViewSet) event_router.register(r'checkinlists', checkin.CheckinListViewSet)
event_router.register(r'checkins', checkin.CheckinViewSet)
event_router.register(r'cartpositions', cart.CartPositionViewSet) event_router.register(r'cartpositions', cart.CartPositionViewSet)
event_router.register(r'scheduled_exports', exporters.ScheduledEventExportViewSet) event_router.register(r'scheduled_exports', exporters.ScheduledEventExportViewSet)
event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters') event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters')
@@ -111,6 +112,7 @@ item_router = routers.DefaultRouter()
item_router.register(r'variations', item.ItemVariationViewSet) item_router.register(r'variations', item.ItemVariationViewSet)
item_router.register(r'addons', item.ItemAddOnViewSet) item_router.register(r'addons', item.ItemAddOnViewSet)
item_router.register(r'bundles', item.ItemBundleViewSet) item_router.register(r'bundles', item.ItemBundleViewSet)
item_router.register(r'program_times', item.ItemProgramTimeViewSet)
order_router = routers.DefaultRouter() order_router = routers.DefaultRouter()
order_router.register(r'payments', order.PaymentViewSet) order_router.register(r'payments', order.PaymentViewSet)
+34 -1
View File
@@ -56,7 +56,8 @@ from pretix.api.serializers.checkin import (
) )
from pretix.api.serializers.item import QuestionSerializer from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import ( from pretix.api.serializers.order import (
CheckinListOrderPositionSerializer, FailedCheckinSerializer, CheckinListOrderPositionSerializer, CheckinSerializer,
FailedCheckinSerializer,
) )
from pretix.api.views import RichOrderingFilter from pretix.api.views import RichOrderingFilter
from pretix.api.views.order import OrderPositionFilter from pretix.api.views.order import OrderPositionFilter
@@ -96,6 +97,16 @@ with scopes_disabled():
) )
return queryset.filter(expr) return queryset.filter(expr)
class CheckinFilter(FilterSet):
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
created_before = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='lt')
datetime_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
datetime_before = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='lt')
class Meta:
model = Checkin
fields = ['successful', 'error_reason', 'list', 'type', 'gate', 'device', 'auto_checked_in']
class CheckinListViewSet(viewsets.ModelViewSet): class CheckinListViewSet(viewsets.ModelViewSet):
serializer_class = CheckinListSerializer serializer_class = CheckinListSerializer
@@ -1080,3 +1091,25 @@ class CheckinRPCAnnulView(views.APIView):
checkin_annulled.send(ci.position.order.event, checkin=ci) checkin_annulled.send(ci.position.order.event, checkin=ci)
return Response({"status": "ok"}, status=status.HTTP_200_OK) return Response({"status": "ok"}, status=status.HTTP_200_OK)
class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinSerializer
queryset = Checkin.all.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
filterset_class = CheckinFilter
ordering = ('created', 'id')
ordering_fields = ('created', 'datetime', 'id',)
permission = 'can_view_orders'
def get_queryset(self):
qs = Checkin.all.filter().select_related(
"position",
"device",
)
return qs
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
+55 -4
View File
@@ -46,13 +46,13 @@ from rest_framework.response import Response
from pretix.api.pagination import TotalOrderingFilter from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.item import ( from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer, ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
ItemSerializer, ItemVariationSerializer, QuestionOptionSerializer, ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
QuestionSerializer, QuotaSerializer, QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
) )
from pretix.api.views import ConditionalListView from pretix.api.views import ConditionalListView
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
Question, QuestionOption, Quota, ItemVariation, Question, QuestionOption, Quota,
) )
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
@@ -279,6 +279,57 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
) )
class ItemProgramTimeViewSet(viewsets.ModelViewSet):
serializer_class = ItemProgramTimeSerializer
queryset = ItemProgramTime.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter,)
ordering_fields = ('id',)
ordering = ('id',)
permission = None
write_permission = 'can_change_items'
@cached_property
def item(self):
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
def get_queryset(self):
return self.item.program_times.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['item'] = self.item
return ctx
def perform_create(self, serializer):
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
serializer.save(item=item)
item.log_action(
'pretix.event.item.program_times.added',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.item.log_action(
'pretix.event.item.program_times.changed',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
def perform_destroy(self, instance):
super().perform_destroy(instance)
instance.item.log_action(
'pretix.event.item.program_times.removed',
user=self.request.user,
auth=self.request.auth,
data={'start': instance.start, 'end': instance.end}
)
class ItemAddOnViewSet(viewsets.ModelViewSet): class ItemAddOnViewSet(viewsets.ModelViewSet):
serializer_class = ItemAddOnSerializer serializer_class = ItemAddOnSerializer
queryset = ItemAddOn.objects.none() queryset = ItemAddOn.objects.none()
+1
View File
@@ -344,6 +344,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['event'] = self.request.event ctx['event'] = self.request.event
ctx['auth'] = self.request.auth
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true' ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
return ctx return ctx
+1 -1
View File
@@ -149,7 +149,7 @@ class ItemDataExporter(ListExporter):
row += [ row += [
_("Yes") if i.active and v.active else "", _("Yes") if i.active and v.active else "",
", ".join([str(sn.label) for sn in sales_channels]), ", ".join([str(sn.label) for sn in sales_channels]),
v.default_price or i.default_price, v.default_price if v.default_price is not None else i.default_price,
_("Yes") if i.free_price else "", _("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "", str(i.tax_rule) if i.tax_rule else "",
_("Yes") if i.admission else "", _("Yes") if i.admission else "",
+29 -9
View File
@@ -214,21 +214,38 @@ class PasswordRecoverForm(forms.Form):
error_messages = { error_messages = {
'pw_mismatch': _("Please enter the same password twice"), 'pw_mismatch': _("Please enter the same password twice"),
} }
email = forms.EmailField(
max_length=255,
disabled=True,
label=_("Your email address"),
widget=forms.EmailInput(
attrs={'autocomplete': 'username'},
),
)
password = forms.CharField( password = forms.CharField(
label=_('Password'), label=_('Password'),
widget=forms.PasswordInput, widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password',
}),
max_length=4096, max_length=4096,
required=True required=True
) )
password_repeat = forms.CharField( password_repeat = forms.CharField(
label=_('Repeat password'), label=_('Repeat password'),
widget=forms.PasswordInput, widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password',
}),
max_length=4096, max_length=4096,
) )
def __init__(self, user_id=None, *args, **kwargs): def __init__(self, user_id=None, *args, **kwargs):
self.user_id = user_id initial = kwargs.pop('initial', {})
super().__init__(*args, **kwargs) try:
self.user = User.objects.get(id=user_id)
initial['email'] = self.user.email
except User.DoesNotExist:
self.user = None
super().__init__(*args, initial=initial, **kwargs)
def clean(self): def clean(self):
password1 = self.cleaned_data.get('password', '') password1 = self.cleaned_data.get('password', '')
@@ -243,11 +260,7 @@ class PasswordRecoverForm(forms.Form):
def clean_password(self): def clean_password(self):
password1 = self.cleaned_data.get('password', '') password1 = self.cleaned_data.get('password', '')
try: if validate_password(password1, user=self.user) is not None:
user = User.objects.get(id=self.user_id)
except User.DoesNotExist:
user = None
if validate_password(password1, user=user) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid') raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1 return password1
@@ -307,3 +320,10 @@ class ReauthForm(forms.Form):
self.error_messages['inactive'], self.error_messages['inactive'],
code='inactive', code='inactive',
) )
class ConfirmationCodeForm(forms.Form):
code = forms.IntegerField(
label=_('Confirmation code'),
widget=forms.NumberInput(attrs={'class': 'confirmation-code-input', 'inputmode': 'numeric', 'type': 'text'}),
)
+74 -63
View File
@@ -39,37 +39,16 @@ from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password, password_validators_help_texts, validate_password,
) )
from django.db.models import Q from django.db.models import Q
from django.urls.base import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pytz import common_timezones from pytz import common_timezones
from pretix.base.models import User from pretix.base.models import User
from pretix.control.forms import SingleLanguageWidget from pretix.control.forms import SingleLanguageWidget
from pretix.helpers.format import format_map
class UserSettingsForm(forms.ModelForm): class UserSettingsForm(forms.ModelForm):
error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."),
'pw_current': _("Please enter your current password if you want to change your email address "
"or password."),
'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
'pw_equal': _("Please choose a password different to your current one.")
}
old_pw = forms.CharField(max_length=255,
required=False,
label=_("Your current password"),
widget=forms.PasswordInput())
new_pw = forms.CharField(max_length=255,
required=False,
label=_("New password"),
widget=forms.PasswordInput())
new_pw_repeat = forms.CharField(max_length=255,
required=False,
label=_("Repeat new password"),
widget=forms.PasswordInput())
timezone = forms.ChoiceField( timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones), choices=((a, a) for a in common_timezones),
label=_("Default timezone"), label=_("Default timezone"),
@@ -93,11 +72,60 @@ class UserSettingsForm(forms.ModelForm):
self.user = kwargs.pop('user') self.user = kwargs.pop('user')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['email'].required = True self.fields['email'].required = True
if self.user.auth_backend != 'native': self.fields['email'].disabled = True
del self.fields['old_pw'] self.fields['email'].help_text = format_map('<a href="{link}"><span class="fa fa-edit"></span> {text}</a>', {
del self.fields['new_pw'] 'text': _("Change email address"),
del self.fields['new_pw_repeat'] 'link': reverse('control:user.settings.email.change')
self.fields['email'].disabled = True })
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)')),
))
class UserPasswordChangeForm(forms.Form):
error_messages = {
'pw_current': _("Please enter your current password if you want to change your email address "
"or password."),
'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
'pw_equal': _("Please choose a password different to your current one.")
}
email = forms.EmailField(max_length=255,
disabled=True,
label=_("Your email address"),
widget=forms.EmailInput(
attrs={'autocomplete': 'username'},
))
old_pw = forms.CharField(max_length=255,
required=False,
label=_("Your current password"),
widget=forms.PasswordInput(
attrs={'autocomplete': 'current-password'},
))
new_pw = forms.CharField(max_length=255,
required=False,
label=_("New password"),
widget=forms.PasswordInput(
attrs={'autocomplete': 'new-password'},
))
new_pw_repeat = forms.CharField(max_length=255,
required=False,
label=_("Repeat new password"),
widget=forms.PasswordInput(
attrs={'autocomplete': 'new-password'},
))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
initial = kwargs.pop('initial', {})
initial['email'] = self.user.email
super().__init__(*args, initial=initial, **kwargs)
def clean_old_pw(self): def clean_old_pw(self):
old_pw = self.cleaned_data.get('old_pw') old_pw = self.cleaned_data.get('old_pw')
@@ -121,15 +149,6 @@ class UserSettingsForm(forms.ModelForm):
return old_pw return old_pw
def clean_email(self):
email = self.cleaned_data['email']
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
raise forms.ValidationError(
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',
)
return email
def clean_new_pw(self): def clean_new_pw(self):
password1 = self.cleaned_data.get('new_pw', '') password1 = self.cleaned_data.get('new_pw', '')
if password1 and validate_password(password1, user=self.user) is not None: if password1 and validate_password(password1, user=self.user) is not None:
@@ -148,32 +167,24 @@ class UserSettingsForm(forms.ModelForm):
code='pw_mismatch' code='pw_mismatch'
) )
def clean(self):
password1 = self.cleaned_data.get('new_pw')
email = self.cleaned_data.get('email')
old_pw = self.cleaned_data.get('old_pw')
if (password1 or email != self.user.email) and not old_pw: class UserEmailChangeForm(forms.Form):
error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."),
}
old_email = forms.EmailField(label=_('Old email address'), disabled=True)
new_email = forms.EmailField(label=_('New email address'))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
def clean_new_email(self):
email = self.cleaned_data['new_email']
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists():
raise forms.ValidationError( raise forms.ValidationError(
self.error_messages['pw_current'], self.error_messages['duplicate_identifier'],
code='pw_current' code='duplicate_identifier',
) )
return email
if password1 and password1 == old_pw:
raise forms.ValidationError(
self.error_messages['pw_equal'],
code='pw_equal'
)
if password1:
self.instance.set_password(password1)
return self.cleaned_data
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)')),
))
+92 -13
View File
@@ -23,6 +23,7 @@ import datetime
import logging import logging
import math import math
import re import re
import textwrap
import unicodedata import unicodedata
from collections import defaultdict from collections import defaultdict
from decimal import Decimal from decimal import Decimal
@@ -752,11 +753,59 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
return dt.astimezone(tz).date() return dt.astimezone(tz).date()
total = Decimal('0.00') total = Decimal('0.00')
if has_taxes:
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
else:
colwidths = [a * doc.width for a in (.65, .20, .15)]
for (description, tax_rate, tax_name, net_value, gross_value, subevent, period_start, period_end), lines in addon_aware_groupby( for (description, tax_rate, tax_name, net_value, gross_value, subevent, period_start, period_end), lines in addon_aware_groupby(
all_lines, all_lines,
key=_group_key, key=_group_key,
is_addon=lambda l: l.description.startswith(" +"), is_addon=lambda l: l.description.startswith(" +"),
): ):
# split description into multiple Paragraphs so each fits in a table cell on a single page
# otherwise PDF-build fails
description_p_list = []
# normalize linebreaks to newlines instead of HTML so we can safely substring
description = description.replace('<br>', '<br />').replace('<br />\n', '\n').replace('<br />', '\n')
# start first line with different settings than the rest of the description
curr_description = description.split("\n", maxsplit=1)[0]
cellpadding = 6 # default cellpadding is only set on right side of column
max_width = colwidths[0] - cellpadding
max_height = self.stylesheet['Normal'].leading * 5
p_style = self.stylesheet['Normal']
for __ in range(1000):
p = FontFallbackParagraph(
self._clean_text(curr_description, tags=['br']),
p_style
)
h = p.wrap(max_width, doc.height)[1]
if h <= max_height:
description_p_list.append(p)
if curr_description == description:
break
description = description[len(curr_description):].lstrip()
curr_description = description.split("\n", maxsplit=1)[0]
# use different settings for all except first line
max_width = sum(colwidths[0:3 if has_taxes else 2]) - cellpadding
max_height = self.stylesheet['Fineprint'].leading * 8
p_style = self.stylesheet['Fineprint']
continue
if not description_p_list:
# first "manual" line is larger than 5 "real" lines => only allow one line and set rest in Fineprint
max_height = self.stylesheet['Normal'].leading
if h > max_height * 1.1:
# quickly bring the text-length down to a managable length to then stepwise reduce
wrap_to = math.ceil(len(curr_description) * max_height * 1.1 / h)
else:
# trim to 95% length, but at most 10 chars to not have strangely short lines in the middle of a paragraph
wrap_to = max(len(curr_description) - 10, math.ceil(len(curr_description) * 0.95))
curr_description = textwrap.wrap(curr_description, wrap_to, replace_whitespace=False, drop_whitespace=False)[0]
# Try to be clever and figure out when organizers would want to show the period. This heuristic is # Try to be clever and figure out when organizers would want to show the period. This heuristic is
# not perfect and the only "fully correct" way would be to include the period on every line always, # not perfect and the only "fully correct" way would be to include the period on every line always,
# however this will cause confusion (a) due to useless repetition of the same date all over the invoice # however this will cause confusion (a) due to useless repetition of the same date all over the invoice
@@ -810,7 +859,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
# Group together at the end of the invoice # Group together at the end of the invoice
request_show_service_date = period_line request_show_service_date = period_line
elif period_line: elif period_line:
description += "\n" + period_line description_p_list.append(FontFallbackParagraph(
period_line,
self.stylesheet['Fineprint']
))
lines = list(lines) lines = list(lines)
if has_taxes: if has_taxes:
@@ -819,13 +871,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net_price=money_filter(net_value, self.invoice.event.currency), net_price=money_filter(net_value, self.invoice.event.currency),
gross_price=money_filter(gross_value, self.invoice.event.currency), gross_price=money_filter(gross_value, self.invoice.event.currency),
) )
description = description + "\n" + single_price_line description_p_list.append(FontFallbackParagraph(
single_price_line,
self.stylesheet['Fineprint']
))
tdata.append(( tdata.append((
FontFallbackParagraph( description_p_list.pop(0),
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
str(len(lines)), str(len(lines)),
localize(tax_rate) + " %", localize(tax_rate) + " %",
FontFallbackParagraph( FontFallbackParagraph(
@@ -837,23 +889,52 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
self.stylesheet['NormalRight'] self.stylesheet['NormalRight']
), ),
)) ))
for p in description_p_list:
tdata.append((p, "", "", "", ""))
tstyledata.append((
'SPAN',
(0, len(tdata) - 1),
(2, len(tdata) - 1),
))
else: else:
if len(lines) > 1: if len(lines) > 1:
single_price_line = pgettext('invoice', 'Single price: {price}').format( single_price_line = pgettext('invoice', 'Single price: {price}').format(
price=money_filter(gross_value, self.invoice.event.currency), price=money_filter(gross_value, self.invoice.event.currency),
) )
description = description + "\n" + single_price_line description_p_list.append(FontFallbackParagraph(
single_price_line,
self.stylesheet['Fineprint']
))
tdata.append(( tdata.append((
FontFallbackParagraph( description_p_list.pop(0),
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
str(len(lines)), str(len(lines)),
FontFallbackParagraph( FontFallbackParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight'] self.stylesheet['NormalRight']
), ),
)) ))
for p in description_p_list:
tdata.append((p, "", ""))
tstyledata.append((
'SPAN',
(0, len(tdata) - 1),
(1, len(tdata) - 1),
))
tstyledata += [
(
'BOTTOMPADDING',
(0, len(tdata) - len(description_p_list)),
(-1, len(tdata) - 2),
0
),
(
'TOPPADDING',
(0, len(tdata) - len(description_p_list)),
(-1, len(tdata) - 1),
0
),
]
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines) taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines) grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
total += gross_value * len(lines) total += gross_value * len(lines)
@@ -863,13 +944,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '', FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
money_filter(total, self.invoice.event.currency) money_filter(total, self.invoice.event.currency)
]) ])
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
else: else:
tdata.append([ tdata.append([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
money_filter(total, self.invoice.event.currency) money_filter(total, self.invoice.event.currency)
]) ])
colwidths = [a * doc.width for a in (.65, .20, .15)]
if not self.invoice.is_cancellation: if not self.invoice.is_cancellation:
if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING: if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING:
@@ -0,0 +1,81 @@
# Generated by Django 4.2.17 on 2025-04-20 13:58
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0292_giftcard_customer"),
]
operations = [
migrations.AddField(
model_name="cartposition",
name="price_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="cartposition",
name="tax_code",
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name="cartposition",
name="tax_value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="orderfee",
name="tax_value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="orderfee",
name="value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="orderposition",
name="price_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="orderposition",
name="tax_value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="transaction",
name="price_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="transaction",
name="tax_value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="order",
name="tax_rounding_mode",
field=models.CharField(default="line", max_length=100),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 4.2.19 on 2025-08-11 10:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0293_cartposition_price_includes_rounding_correction_and_more'),
]
operations = [
migrations.CreateModel(
name='ItemProgramTime',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('start', models.DateTimeField()),
('end', models.DateTimeField()),
('item',
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='program_times',
to='pretixbase.item')),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.23 on 2025-09-04 16:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0294_item_program_time"),
]
operations = [
migrations.AddField(
model_name="user",
name="is_verified",
field=models.BooleanField(default=False),
),
]
+3 -2
View File
@@ -36,8 +36,9 @@ from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
from .invoices import Invoice, InvoiceLine, invoice_filename from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import ( from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota, ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
SubEventItem, SubEventItemVariation, itempicture_upload_to, QuestionOption, Quota, SubEventItem, SubEventItemVariation,
itempicture_upload_to,
) )
from .log import LogEntry from .log import LogEntry
from .media import ReusableMedium from .media import ReusableMedium
+75
View File
@@ -35,6 +35,7 @@
import binascii import binascii
import json import json
import operator import operator
import secrets
from datetime import timedelta from datetime import timedelta
from functools import reduce from functools import reduce
@@ -44,6 +45,7 @@ from django.contrib.auth.models import (
) )
from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.tokens import default_token_generator
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import BadRequest, PermissionDenied
from django.db import IntegrityError, models, transaction from django.db import IntegrityError, models, transaction
from django.db.models import Q from django.db.models import Q
from django.utils.crypto import get_random_string, salted_hmac from django.utils.crypto import get_random_string, salted_hmac
@@ -239,9 +241,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
USERNAME_FIELD = 'email' USERNAME_FIELD = 'email'
REQUIRED_FIELDS = [] REQUIRED_FIELDS = []
MAX_CONFIRMATION_CODE_ATTEMPTS = 10
email = models.EmailField(unique=True, db_index=True, null=True, blank=True, email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
verbose_name=_('Email'), max_length=190) verbose_name=_('Email'), max_length=190)
is_verified = models.BooleanField(default=False, verbose_name=_('Verified email address'))
fullname = models.CharField(max_length=255, blank=True, null=True, fullname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Full name')) verbose_name=_('Full name'))
is_active = models.BooleanField(default=True, is_active = models.BooleanField(default=True,
@@ -353,6 +357,77 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
except SendMailException: except SendMailException:
pass # Already logged pass # Already logged
def send_confirmation_code(self, session, reason, email=None, state=None):
"""
Sends a confirmation code via email to the user. The code is only valid for the action specified by `reason`.
The email is either sent to the email address currently on file for the user, or to the one given in the optional `email` parameter.
A `state` value can be provided which is bound to this confirmation code, and returned on successfully checking the code.
:param session: the user's request session
:param reason: the action which should be confirmed using this confirmation code (currently, only `email_change` is allowed)
:param email: optional, the email address to send the confirmation code to
:param state: optional
"""
from pretix.base.services.mail import mail
with language(self.locale):
if reason == 'email_change':
msg = str(_('to confirm changing your email address from {old_email}\nto {new_email}, use the following code:').format(
old_email=self.email, new_email=email,
))
elif reason == 'email_verify':
msg = str(_('to confirm that your email address {email} belongs to your pretix account, use the following code:').format(
email=self.email,
))
else:
raise Exception('Invalid confirmation code reason')
code = "%07d" % secrets.SystemRandom().randint(0, 9999999)
session['user_confirmation_code:' + reason] = {
'code': code,
'state': state,
'attempts': 0,
}
mail(
email or self.email,
_('pretix confirmation code'),
'pretixcontrol/email/confirmation_code.txt',
{
'user': self,
'reason': msg,
'code': code,
},
event=None,
user=self,
locale=self.locale
)
def check_confirmation_code(self, session, reason, code):
"""
Checks a confirmation code entered by the user against the valid code stored in the session.
If the code is correct, an optional state bound to the code is returned.
If the code is incorrect, PermissionDenied is raised. If the code could not be validated, either because no
code for the given reason is stored, or the number of input attempts is exceeded, BadRequest is raised.
:param session: the user's request session
:param reason: the action which should be confirmed using this confirmation code
:param code: the code entered by the user
:return: optional state bound to this code using the state parameter of send_confirmation_code, None otherwise
"""
stored = session.get('user_confirmation_code:' + reason)
if not stored:
raise BadRequest
if stored['attempts'] > User.MAX_CONFIRMATION_CODE_ATTEMPTS:
raise BadRequest
if int(stored['code']) == int(code):
del session['user_confirmation_code:' + reason]
return stored['state']
else:
stored['attempts'] += 1
session['user_confirmation_code:' + reason] = stored
raise PermissionDenied
def send_password_reset(self): def send_password_reset(self):
from pretix.base.services.mail import mail from pretix.base.services.mail import mail
+12 -1
View File
@@ -715,6 +715,12 @@ class Event(EventMixin, LoggedModel):
self.settings.name_scheme = 'given_family' self.settings.name_scheme = 'given_family'
self.settings.payment_banktransfer_invoice_immediately = True self.settings.payment_banktransfer_invoice_immediately = True
self.settings.low_availability_percentage = 10 self.settings.low_availability_percentage = 10
self.settings.mail_send_order_free_attendee = True
self.settings.mail_send_order_placed_attendee = True
self.settings.mail_send_order_paid_attendee = True
self.settings.mail_send_order_approved_attendee = True
self.settings.mail_send_order_approved_free_attendee = True
self.settings.mail_text_download_reminder_attendee = True
@property @property
def social_image(self): def social_image(self):
@@ -847,7 +853,7 @@ class Event(EventMixin, LoggedModel):
from ..signals import event_copy_data from ..signals import event_copy_data
from . import ( from . import (
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
ItemVariationMetaValue, Question, Quota, ItemProgramTime, ItemVariationMetaValue, Question, Quota,
) )
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin. # Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
@@ -990,6 +996,11 @@ class Event(EventMixin, LoggedModel):
ia.bundled_variation = variation_map[ia.bundled_variation.pk] ia.bundled_variation = variation_map[ia.bundled_variation.pk]
ia.save(force_insert=True) ia.save(force_insert=True)
for ipt in ItemProgramTime.objects.filter(item__event=other).prefetch_related('item'):
ipt.pk = None
ipt.item = item_map[ipt.item.pk]
ipt.save(force_insert=True)
quota_map = {} quota_map = {}
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'): for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
quota_map[q.pk] = q quota_map[q.pk] = q
+24
View File
@@ -2294,3 +2294,27 @@ class ItemVariationMetaValue(LoggedModel):
class Meta: class Meta:
unique_together = ('variation', 'property') unique_together = ('variation', 'property')
class ItemProgramTime(models.Model):
"""
This model can be used to add a program time to an item.
:param item: The item the program time applies to
:type item: Item
:param start: The date and time this program time starts
:type start: datetime
:param end: The date and time this program time ends
:type end: datetime
"""
item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE)
start = models.DateTimeField(verbose_name=_("Start"))
end = models.DateTimeField(verbose_name=_("End"))
def clean(self):
self.clean_start_end(start=self.start, end=self.end)
super().clean()
def clean_start_end(self, start: datetime = None, end: datetime = None):
if start and end and start > end:
raise ValidationError(_("The program end must not be before the program start."))
+117 -19
View File
@@ -81,7 +81,7 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Customer, User from pretix.base.models import Customer, User
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES, ROUNDING_MODES
from pretix.base.signals import allow_ticket_download, order_gracefully_delete from pretix.base.signals import allow_ticket_download, order_gracefully_delete
from pretix.base.timemachine import time_machine_now from pretix.base.timemachine import time_machine_now
@@ -324,6 +324,11 @@ class Order(LockModel, LoggedModel):
# Invoice needs to be re-issued when the order is paid again # Invoice needs to be re-issued when the order is paid again
default=False, default=False,
) )
tax_rounding_mode = models.CharField(
max_length=100,
choices=ROUNDING_MODES,
default="line",
)
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer') objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
@@ -1259,7 +1264,8 @@ class Order(LockModel, LoggedModel):
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys()) keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
create = [] create = []
for k in keys: for k in keys:
positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype, taxcode = k (positionid, itemid, variationid, subeventid, price, price_includes_rounding_correction, taxrate,
taxruleid, taxvalue, taxvalue_includes_rounding_correction, feetype, internaltype, taxcode) = k
d = target_transaction_count[k] - current_transaction_count[k] d = target_transaction_count[k] - current_transaction_count[k]
if d: if d:
create.append(Transaction( create.append(Transaction(
@@ -1272,9 +1278,11 @@ class Order(LockModel, LoggedModel):
variation_id=variationid, variation_id=variationid,
subevent_id=subeventid, subevent_id=subeventid,
price=price, price=price,
price_includes_rounding_correction=price_includes_rounding_correction,
tax_rate=taxrate, tax_rate=taxrate,
tax_rule_id=taxruleid, tax_rule_id=taxruleid,
tax_value=taxvalue, tax_value=taxvalue,
tax_value_includes_rounding_correction=taxvalue_includes_rounding_correction,
tax_code=taxcode, tax_code=taxcode,
fee_type=feetype, fee_type=feetype,
internal_type=internaltype, internal_type=internaltype,
@@ -1449,7 +1457,22 @@ class QuestionAnswer(models.Model):
super().delete(**kwargs) super().delete(**kwargs)
class AbstractPosition(models.Model): class RoundingCorrectionMixin:
@property
def gross_price_before_rounding(self):
return self.price - self.price_includes_rounding_correction
@property
def tax_value_before_rounding(self):
return self.tax_value - self.tax_value_includes_rounding_correction
@property
def net_price_before_rounding(self):
return self.gross_price_before_rounding - self.tax_value_before_rounding
class AbstractPosition(RoundingCorrectionMixin, models.Model):
""" """
A position can either be one line of an order or an item placed in a cart. A position can either be one line of an order or an item placed in a cart.
@@ -1499,6 +1522,9 @@ class AbstractPosition(models.Model):
decimal_places=2, max_digits=13, decimal_places=2, max_digits=13,
verbose_name=_("Price") verbose_name=_("Price")
) )
price_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
attendee_name_cached = models.CharField( attendee_name_cached = models.CharField(
max_length=255, max_length=255,
verbose_name=_("Attendee name"), verbose_name=_("Attendee name"),
@@ -2272,7 +2298,7 @@ class ActivePositionManager(ScopedManager(organizer='order__event__organizer')._
return super().get_queryset().filter(canceled=False) return super().get_queryset().filter(canceled=False)
class OrderFee(models.Model): class OrderFee(RoundingCorrectionMixin, models.Model):
""" """
An OrderFee object represents a fee that is added to the order total independently of An OrderFee object represents a fee that is added to the order total independently of
the actual positions. This might for example be a payment or a shipping fee. the actual positions. This might for example be a payment or a shipping fee.
@@ -2322,6 +2348,9 @@ class OrderFee(models.Model):
decimal_places=2, max_digits=13, decimal_places=2, max_digits=13,
verbose_name=_("Value") verbose_name=_("Value")
) )
value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
order = models.ForeignKey( order = models.ForeignKey(
Order, Order,
verbose_name=_("Order"), verbose_name=_("Order"),
@@ -2350,6 +2379,9 @@ class OrderFee(models.Model):
max_digits=13, decimal_places=2, max_digits=13, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
) )
tax_value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
canceled = models.BooleanField(default=False) canceled = models.BooleanField(default=False)
all = ScopedManager(organizer='order__event__organizer') all = ScopedManager(organizer='order__event__organizer')
@@ -2398,17 +2430,23 @@ class OrderFee(models.Model):
self.fee_type, self.value self.fee_type, self.value
) )
def _calculate_tax(self, tax_rule=None, invoice_address=None): def _calculate_tax(self, tax_rule=None, invoice_address=None, event=None):
if tax_rule: if tax_rule:
self.tax_rule = tax_rule self.tax_rule = tax_rule
try: if invoice_address:
ia = invoice_address or self.order.invoice_address ia = invoice_address
except InvoiceAddress.DoesNotExist: elif hasattr(self, "order"):
try:
ia = self.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
else:
ia = None ia = None
if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rule_payment == "default": event = event or self.order.event
self.tax_rule = self.order.event.cached_default_tax_rule if not self.tax_rule and self.fee_type == "payment" and event.settings.tax_rule_payment == "default":
self.tax_rule = event.cached_default_tax_rule
if self.tax_rule: if self.tax_rule:
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True) tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
@@ -2443,6 +2481,24 @@ class OrderFee(models.Model):
self.order.touch() self.order.touch()
super().delete(**kwargs) super().delete(**kwargs)
# For historical reasons, OrderFee has "value", but OrderPosition has "price". These properties
# help using them the same way.
@property
def price(self):
return self.value
@price.setter
def price(self, value):
self.value = value
@property
def price_includes_rounding_correction(self):
return self.value_includes_rounding_correction
@price_includes_rounding_correction.setter
def price_includes_rounding_correction(self, value):
self.value_includes_rounding_correction = value
class OrderPosition(AbstractPosition): class OrderPosition(AbstractPosition):
""" """
@@ -2522,6 +2578,9 @@ class OrderPosition(AbstractPosition):
max_digits=13, decimal_places=2, max_digits=13, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
) )
tax_value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00"),
)
secret = models.CharField(max_length=255, null=False, blank=False, db_index=True) secret = models.CharField(max_length=255, null=False, blank=False, db_index=True)
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True) web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
@@ -2694,7 +2753,14 @@ class OrderPosition(AbstractPosition):
setattr(op, f.name, cp_mapping[cartpos.addon_to_id]) setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
else: else:
setattr(op, f.name, getattr(cartpos, f.name)) setattr(op, f.name, getattr(cartpos, f.name))
op._calculate_tax()
op.tax_value = cartpos.tax_value
op.tax_value_includes_rounding_correction = cartpos.tax_value_includes_rounding_correction
op.tax_rate = cartpos.tax_rate
op.tax_code = cartpos.tax_code
op.tax_rule = cartpos.item.tax_rule
# todo: is removing this safe? op._calculate_tax()
if cartpos.voucher: if cartpos.voucher:
op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher
@@ -3027,6 +3093,9 @@ class Transaction(models.Model):
decimal_places=2, max_digits=13, decimal_places=2, max_digits=13,
verbose_name=_("Price") verbose_name=_("Price")
) )
price_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
tax_rate = models.DecimalField( tax_rate = models.DecimalField(
max_digits=7, decimal_places=2, max_digits=7, decimal_places=2,
verbose_name=_('Tax rate') verbose_name=_('Tax rate')
@@ -3044,6 +3113,9 @@ class Transaction(models.Model):
max_digits=13, decimal_places=2, max_digits=13, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
) )
tax_value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
fee_type = models.CharField( fee_type = models.CharField(
max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True
) )
@@ -3073,14 +3145,19 @@ class Transaction(models.Model):
@staticmethod @staticmethod
def key(obj): def key(obj):
if isinstance(obj, Transaction): if isinstance(obj, Transaction):
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate, return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code) obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id,
obj.tax_value, obj.tax_value_includes_rounding_correction, obj.fee_type,
obj.internal_type, obj.tax_code)
elif isinstance(obj, OrderPosition): elif isinstance(obj, OrderPosition):
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate, return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
obj.tax_rule_id, obj.tax_value, None, None, obj.tax_code) obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id,
obj.tax_value, obj.tax_value_includes_rounding_correction, None,
None, obj.tax_code)
elif isinstance(obj, OrderFee): elif isinstance(obj, OrderFee):
return (None, None, None, None, obj.value, obj.tax_rate, return (None, None, None, None, obj.value, obj.value_includes_rounding_correction,
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code) obj.tax_rate, obj.tax_rule_id, obj.tax_value, obj.tax_value_includes_rounding_correction,
obj.fee_type, obj.internal_type, obj.tax_code)
raise ValueError('invalid state') # noqa raise ValueError('invalid state') # noqa
@property @property
@@ -3091,6 +3168,14 @@ class Transaction(models.Model):
def full_tax_value(self): def full_tax_value(self):
return self.tax_value * self.count return self.tax_value * self.count
@property
def full_price_includes_rounding_correction(self):
return self.price_includes_rounding_correction * self.count
@property
def full_tax_value_includes_rounding_correction(self):
return self.tax_value_includes_rounding_correction * self.count
class CartPosition(AbstractPosition): class CartPosition(AbstractPosition):
""" """
@@ -3131,6 +3216,13 @@ class CartPosition(AbstractPosition):
max_digits=7, decimal_places=2, default=Decimal('0.00'), max_digits=7, decimal_places=2, default=Decimal('0.00'),
verbose_name=_('Tax rate') verbose_name=_('Tax rate')
) )
tax_code = models.CharField(
max_length=190,
null=True, blank=True,
)
tax_value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
listed_price = models.DecimalField( listed_price = models.DecimalField(
decimal_places=2, max_digits=13, null=True, decimal_places=2, max_digits=13, null=True,
) )
@@ -3171,9 +3263,15 @@ class CartPosition(AbstractPosition):
@property @property
def tax_value(self): def tax_value(self):
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))), price = self.gross_price_before_rounding
net = round_decimal(price - (price * (1 - 100 / (100 + self.tax_rate))),
self.event.currency) self.event.currency)
return self.price - net return self.gross_price_before_rounding - net + self.tax_value_includes_rounding_correction
@tax_value.setter
def tax_value(self, value):
# ignore, tax value is always computed on the fly
pass
@cached_property @cached_property
def sort_key(self): def sort_key(self):
+2 -2
View File
@@ -280,13 +280,13 @@ class Seat(models.Model):
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None,
sales_channel='web', sales_channel='web',
ignore_distancing=False, distance_ignore_cart_id=None): ignore_distancing=False, distance_ignore_cart_id=None, always_allow_blocked=False):
from .orders import Order from .orders import Order
from .organizer import SalesChannel from .organizer import SalesChannel
if isinstance(sales_channel, SalesChannel): if isinstance(sales_channel, SalesChannel):
sales_channel = sales_channel.identifier sales_channel = sales_channel.identifier
if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel: if not always_allow_blocked and self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
return False return False
opqs = self.orderposition_set.filter( opqs = self.orderposition_set.filter(
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID], order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID],
+10 -6
View File
@@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map from pretix.helpers.format import format_map
from pretix.helpers.money import DecimalTextInput from pretix.helpers.money import DecimalTextInput
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.views import get_cart, get_cart_total from pretix.presale.views import get_cart
from pretix.presale.views.cart import cart_session, get_or_create_cart_id from pretix.presale.views.cart import cart_session, get_or_create_cart_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1149,12 +1149,16 @@ class FreeOrderProvider(BasePaymentProvider):
from .services.cart import get_fees from .services.cart import get_fees
cart = get_cart(request) cart = get_cart(request)
total = get_cart_total(request)
try: try:
total += sum([f.value for f in get_fees(self.event, request, total, None, None, cart)]) fees = get_fees(event=request.event, request=request,
invoice_address=None,
payments=None, positions=cart)
except TaxRule.SaleNotAllowed: except TaxRule.SaleNotAllowed:
# ignore for now, will fail on order creation # ignore for now, will fail on order creation
pass fees = []
total = sum([c.price for c in cart]) + sum([f.value for f in fees])
return total == 0 return total == 0
def order_change_allowed(self, order: Order) -> bool: def order_change_allowed(self, order: Order) -> bool:
@@ -1373,7 +1377,7 @@ class GiftCardPayment(BasePaymentProvider):
execute_payment_needs_user = False execute_payment_needs_user = False
verbose_name = _("Gift card") verbose_name = _("Gift card")
payment_form_class = GiftCardPaymentForm payment_form_class = GiftCardPaymentForm
payment_form_template_name = 'pretixcontrol/giftcards/checkout.html' payment_form_template_name = 'pretixpresale/giftcard/checkout.html'
@cached_property @cached_property
def customer_gift_cards(self): def customer_gift_cards(self):
@@ -1500,7 +1504,7 @@ class GiftCardPayment(BasePaymentProvider):
return super().order_change_allowed(order) and self.event.organizer.has_gift_cards return super().order_change_allowed(order) and self.event.organizer.has_gift_cards
def checkout_confirm_render(self, request, order=None, info_data=None) -> str: def checkout_confirm_render(self, request, order=None, info_data=None) -> str:
return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({ return get_template('pretixpresale/giftcard/checkout_confirm.html').render({
'info_data': info_data, 'info_data': info_data,
}) })
+17
View File
@@ -84,6 +84,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format from pretix.base.templatetags.phone_format import phone_format
from pretix.helpers.daterange import datetimerange
from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
@@ -490,6 +491,12 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT" "TIME_FORMAT"
) if op.valid_until else "" ) if op.valid_until else ""
}), }),
("program_times", {
"label": _("Program times: date and time"),
"editor_sample": _(
"2017-05-31 10:00 12:00\n2017-05-31 14:00 16:00\n2017-05-31 14:00 2017-06-01 14:00"),
"evaluate": lambda op, order, ev: get_program_times(op, ev)
}),
("medium_identifier", { ("medium_identifier", {
"label": _("Reusable Medium ID"), "label": _("Reusable Medium ID"),
"editor_sample": "ABC1234DEF4567", "editor_sample": "ABC1234DEF4567",
@@ -734,6 +741,16 @@ def get_seat(op: OrderPosition):
return None return None
def get_program_times(op: OrderPosition, ev: Event):
return '\n'.join([
datetimerange(
pt.start.astimezone(ev.timezone),
pt.end.astimezone(ev.timezone),
as_html=False
) for pt in op.item.program_times.all()
])
def generate_compressed_addon_list(op, order, event): def generate_compressed_addon_list(op, order, event):
itemcount = defaultdict(int) itemcount = defaultdict(int)
addons = [p for p in ( addons = [p for p in (
+1 -1
View File
@@ -350,7 +350,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
ocm.add_fee(f) ocm.add_fee(f)
if dry_run: if dry_run:
refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff), Decimal("0.00")) refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff_guesstimate), Decimal("0.00"))
else: else:
ocm.commit() ocm.commit()
refund_amount = payment_refund_sum - o.total refund_amount = payment_refund_sum - o.total
+49 -21
View File
@@ -66,8 +66,8 @@ from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException, lock_objects from pretix.base.services.locking import LockTimeoutException, lock_objects
from pretix.base.services.pricing import ( from pretix.base.services.pricing import (
apply_discounts, get_line_price, get_listed_price, get_price, apply_discounts, apply_rounding, get_line_price, get_listed_price,
is_included_for_free, get_price, is_included_for_free,
) )
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask from pretix.base.services.tasks import ProfiledEventTask
@@ -1430,11 +1430,12 @@ class CartManager:
) )
for cp, (new_price, discount) in zip(positions, discount_results): for cp, (new_price, discount) in zip(positions, discount_results):
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None): if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None):
diff += new_price - cp.price diff += new_price - cp.gross_price_before_rounding
cp.price = new_price cp.price = new_price
cp.price_includes_rounding_correction = Decimal("0.00")
cp.discount = discount cp.discount = discount
cp.save(update_fields=['price', 'discount']) cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount'])
return diff return diff
@@ -1493,30 +1494,53 @@ def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: D
add_payment_to_cart_session(cs, provider, min_value, max_value, info_data) add_payment_to_cart_session(cs, provider, min_value, max_value, info_data)
def get_fees(event, request, total, invoice_address, payments, positions): def get_fees(event, request, _total_ignored_=None, invoice_address=None, payments=None, positions=None):
"""
Return all fees that would be created for the current cart. Also implicitly applies rounding on the order
positions. A recommended usage pattern to compute the total looks like this::
cart = get_cart(request)
fees = get_fees(
event=request.event,
request=request,
invoice_address=cached_invoice_address(request),
payments=None,
positions=cart,
)
total = sum([c.price for c in cart]) + sum([f.value for f in fees])
"""
if payments and not isinstance(payments, list): if payments and not isinstance(payments, list):
raise TypeError("payments must now be a list") raise TypeError("payments must now be a list")
if positions is None:
raise TypeError("Must pass positions, parameter is only optional for backwards-compat reasons")
fees = [] fees = []
total = sum([c.gross_price_before_rounding for c in positions])
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address, for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
total=total, positions=positions, payment_requests=payments): positions=positions, total=total, payment_requests=payments):
if resp: if resp:
fees += resp fees += resp
total = total + sum(f.value for f in fees) for fee in fees:
fee._calculate_tax(invoice_address=invoice_address, event=event)
if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
if total != 0 and payments: if total != 0 and payments:
total_remaining = total payments_assigned = Decimal("0.00")
for p in payments: for p in payments:
# This algorithm of treating min/max values and fees needs to stay in sync between the following # This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base: # places in the code base:
# - pretix.base.services.cart.get_fees # - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees # - pretix.base.services.orders._get_fees
# - pretix.presale.views.CartMixin.current_selected_payments # - pretix.presale.views.CartMixin.current_selected_payments
if p.get('min_value') and total_remaining < Decimal(p['min_value']): if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']):
continue continue
to_pay = total_remaining to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']): if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value'])) to_pay = min(to_pay, Decimal(p['max_value']))
@@ -1525,28 +1549,32 @@ def get_fees(event, request, total, invoice_address, payments, positions):
continue continue
payment_fee = pprov.calculate_fee(to_pay) payment_fee = pprov.calculate_fee(to_pay)
total_remaining += payment_fee
to_pay += payment_fee
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
total_remaining -= to_pay
if payment_fee: if payment_fee:
if event.settings.tax_rule_payment == "default": if event.settings.tax_rule_payment == "default":
payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero() payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero()
else: else:
payment_fee_tax_rule = TaxRule.zero() payment_fee_tax_rule = TaxRule.zero()
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address) payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address)
fees.append(OrderFee( pf = OrderFee(
fee_type=OrderFee.FEE_TYPE_PAYMENT, fee_type=OrderFee.FEE_TYPE_PAYMENT,
value=payment_fee, value=payment_fee,
tax_rate=payment_fee_tax.rate, tax_rate=payment_fee_tax.rate,
tax_value=payment_fee_tax.tax, tax_value=payment_fee_tax.tax,
tax_code=payment_fee_tax.code, tax_code=payment_fee_tax.code,
tax_rule=payment_fee_tax_rule tax_rule=payment_fee_tax_rule
)) )
fees.append(pf)
# Re-apply rounding as grand total has changed
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
# Re-calculate to_pay as grand total has changed
to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
payments_assigned += to_pay
return fees return fees
+9 -3
View File
@@ -258,9 +258,15 @@ def build_invoice(invoice: Invoice) -> Invoice:
if resp: if resp:
desc += "<br/>" + resp desc += "<br/>" + resp
for answ in p.answers.all(): answers_qs = p.answers.filter(
if not answ.question.print_on_invoice: question__print_on_invoice=True
continue ).select_related(
'question'
).order_by(
'question__position',
'question__id'
)
for answ in answers_qs:
desc += "<br />{}{} {}".format( desc += "<br />{}{} {}".format(
answ.question.question, answ.question.question,
"" if str(answ.question.question).endswith("?") else ":", "" if str(answ.question.question).endswith("?") else ":",
+160 -79
View File
@@ -95,7 +95,7 @@ from pretix.base.services.memberships import (
create_membership, validate_memberships_in_order, create_membership, validate_memberships_in_order,
) )
from pretix.base.services.pricing import ( from pretix.base.services.pricing import (
apply_discounts, get_listed_price, get_price, apply_discounts, apply_rounding, get_listed_price, get_price,
) )
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
@@ -947,10 +947,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
] ]
) )
for cp, (new_price, discount) in zip(sorted_positions, discount_results): for cp, (new_price, discount) in zip(sorted_positions, discount_results):
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None): if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None):
cp.price = new_price cp.price = new_price
cp.price_includes_rounding_correction = Decimal("0.00")
cp.discount = discount cp.discount = discount
cp.save(update_fields=['price', 'discount']) cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount'])
# After applying discounts, add-on positions might still have a reference to the *old* version of the # After applying discounts, add-on positions might still have a reference to the *old* version of the
# parent position, which can screw up ordering later since the system sees inconsistent data. # parent position, which can screw up ordering later since the system sees inconsistent data.
@@ -973,10 +974,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
raise OrderError(err) raise OrderError(err)
def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress, def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
meta_info: dict, event: Event, require_approval=False): meta_info: dict, event: Event, require_approval=False):
fees = [] fees = []
total = sum([c.price for c in positions]) # Pre-rounding, pre-fee total is used for fee calculation
total = sum([c.gross_price_before_rounding for c in positions])
gift_cards = [] # for backwards compatibility gift_cards = [] # for backwards compatibility
for p in payment_requests: for p in payment_requests:
@@ -987,40 +989,53 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
meta_info=meta_info, positions=positions, gift_cards=gift_cards): meta_info=meta_info, positions=positions, gift_cards=gift_cards):
if resp: if resp:
fees += resp fees += resp
total += sum(f.value for f in fees)
total_remaining = total for fee in fees:
fee._calculate_tax(invoice_address=address, event=event)
if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
# Apply rounding to get final total in case no payment fees will be added
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
payments_assigned = Decimal("0.00")
for p in payment_requests: for p in payment_requests:
# This algorithm of treating min/max values and fees needs to stay in sync between the following # This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base: # places in the code base:
# - pretix.base.services.cart.get_fees # - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees # - pretix.base.services.orders._get_fees
# - pretix.presale.views.CartMixin.current_selected_payments # - pretix.presale.views.CartMixin.current_selected_payments
if p.get('min_value') and total_remaining < Decimal(p['min_value']): if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']):
p['payment_amount'] = Decimal('0.00') p['payment_amount'] = Decimal('0.00')
continue continue
to_pay = total_remaining to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']): if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value'])) to_pay = min(to_pay, Decimal(p['max_value']))
payment_fee = p['pprov'].calculate_fee(to_pay) payment_fee = p['pprov'].calculate_fee(to_pay)
total_remaining += payment_fee
to_pay += payment_fee
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
total_remaining -= to_pay
p['payment_amount'] = to_pay
if payment_fee: if payment_fee:
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee, pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
internal_type=p['pprov'].identifier) internal_type=p['pprov'].identifier)
pf._calculate_tax(invoice_address=address, event=event)
fees.append(pf) fees.append(pf)
p['fee'] = pf p['fee'] = pf
if total_remaining != Decimal('0.00') and not require_approval: # Re-apply rounding as grand total has changed
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
# Re-calculate to_pay as grand total has changed
to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
payments_assigned += to_pay
p['payment_amount'] = to_pay
if total != payments_assigned and not require_approval:
raise OrderError(_("The selected payment methods do not cover the total balance.")) raise OrderError(_("The selected payment methods do not cover the total balance."))
return fees return fees
@@ -1029,7 +1044,7 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime, def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime,
payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None, payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None,
address: InvoiceAddress=None, meta_info: dict=None, shown_total=None, address: InvoiceAddress=None, meta_info: dict=None, shown_total=None,
customer=None, valid_if_pending=False, api_meta: dict=None): customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None):
payments = [] payments = []
try: try:
@@ -1038,10 +1053,13 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
raise OrderError(e.message) raise OrderError(e.message)
require_approval = any(p.requires_approval(invoice_address=address) for p in positions) require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
# Final calculation of fees, also performs final rounding
try: try:
fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval) fees = _apply_rounding_and_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
except TaxRule.SaleNotAllowed: except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked']) raise OrderError(error_messages['country_blocked'])
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees]) total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
order = Order( order = Order(
@@ -1059,6 +1077,7 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
sales_channel=sales_channel, sales_channel=sales_channel,
customer=customer, customer=customer,
valid_if_pending=valid_if_pending, valid_if_pending=valid_if_pending,
tax_rounding_mode=tax_rounding_mode or event.settings.tax_rounding,
) )
if customer: if customer:
order.email_known_to_work = customer.is_verified order.email_known_to_work = customer.is_verified
@@ -1073,12 +1092,6 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
for fee in fees: for fee in fees:
fee.order = order fee.order = order
try:
fee._calculate_tax()
except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked'])
if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
fee.save() fee.save()
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they # Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
@@ -1167,7 +1180,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str], def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web', email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
shown_total=None, customer=None, api_meta: dict=None): shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None):
for p in payment_requests: for p in payment_requests:
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']] p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
if not p['pprov']: if not p['pprov']:
@@ -1273,6 +1286,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
customer=customer, customer=customer,
valid_if_pending=valid_if_pending, valid_if_pending=valid_if_pending,
api_meta=api_meta, api_meta=api_meta,
tax_rounding_mode=tax_rounding_mode,
) )
try: try:
@@ -1656,15 +1670,16 @@ class OrderChangeManager:
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked')) AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked')) RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True): def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order self.order = order
self.user = user self.user = user
self.auth = auth self.auth = auth
self.event = order.event self.event = order.event
self.split_order = None self.split_order = None
self.reissue_invoice = reissue_invoice self.reissue_invoice = reissue_invoice
self.allow_blocked_seats = allow_blocked_seats
self._committed = False self._committed = False
self._totaldiff = 0 self._totaldiff_guesstimate = 0
self._quotadiff = Counter() self._quotadiff = Counter()
self._seatdiff = Counter() self._seatdiff = Counter()
self._operations = [] self._operations = []
@@ -1781,7 +1796,7 @@ class OrderChangeManager:
if position.issued_gift_cards.exists(): if position.issued_gift_cards.exists():
raise OrderError(self.error_messages['gift_card_change']) raise OrderError(self.error_messages['gift_card_change'])
self._totaldiff += price.gross - position.price self._totaldiff_guesstimate += price.gross - position.gross_price_before_rounding
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'): if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
self._invoice_dirty = True self._invoice_dirty = True
@@ -1826,29 +1841,29 @@ class OrderChangeManager:
else: else:
new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency, new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency,
override_tax_rate=new_rate, override_tax_code=new_code) override_tax_rate=new_rate, override_tax_code=new_code)
self._totaldiff += new_tax.gross - pos.price self._totaldiff_guesstimate += new_tax.gross - pos.price
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price)) self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
self._invoice_dirty = True self._invoice_dirty = True
def cancel_fee(self, fee: OrderFee): def cancel_fee(self, fee: OrderFee):
self._totaldiff -= fee.value self._totaldiff_guesstimate -= fee.value
self._operations.append(self.CancelFeeOperation(fee, -fee.value)) self._operations.append(self.CancelFeeOperation(fee, -fee.value))
self._invoice_dirty = True self._invoice_dirty = True
def add_fee(self, fee: OrderFee): def add_fee(self, fee: OrderFee):
self._totaldiff += fee.value self._totaldiff_guesstimate += fee.value
self._invoice_dirty = True self._invoice_dirty = True
self._operations.append(self.AddFeeOperation(fee, fee.value)) self._operations.append(self.AddFeeOperation(fee, fee.value))
def change_fee(self, fee: OrderFee, value: Decimal): def change_fee(self, fee: OrderFee, value: Decimal):
value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross', invoice_address=self._invoice_address, value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross', invoice_address=self._invoice_address,
force_fixed_gross_price=True) force_fixed_gross_price=True)
self._totaldiff += value.gross - fee.value self._totaldiff_guesstimate += value.gross - fee.value
self._invoice_dirty = True self._invoice_dirty = True
self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value)) self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value))
def cancel(self, position: OrderPosition): def cancel(self, position: OrderPosition):
self._totaldiff -= position.price self._totaldiff_guesstimate -= position.price
self._quotadiff.subtract(position.quotas) self._quotadiff.subtract(position.quotas)
self._operations.append(self.CancelOperation(position, -position.price)) self._operations.append(self.CancelOperation(position, -position.price))
if position.seat: if position.seat:
@@ -1914,7 +1929,7 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'): if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True self._invoice_dirty = True
self._totaldiff += price.gross self._totaldiff_guesstimate += price.gross
self._quotadiff.update(new_quotas) self._quotadiff.update(new_quotas)
if seat: if seat:
self._seatdiff.update([seat]) self._seatdiff.update([seat])
@@ -2183,7 +2198,7 @@ class OrderChangeManager:
for seat, diff in self._seatdiff.items(): for seat, diff in self._seatdiff.items():
if diff <= 0: if diff <= 0:
continue continue
if not seat.is_available(sales_channel=self.order.sales_channel, ignore_distancing=True) or diff > 1: if not seat.is_available(sales_channel=self.order.sales_channel, ignore_distancing=True, always_allow_blocked=self.allow_blocked_seats) or diff > 1:
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name)) raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
if self.event.has_subevents: if self.event.has_subevents:
@@ -2210,8 +2225,8 @@ class OrderChangeManager:
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff): if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
raise OrderError(self.error_messages['quota'].format(name=quota.name)) raise OrderError(self.error_messages['quota'].format(name=quota.name))
def _check_paid_price_change(self): def _check_paid_price_change(self, totaldiff):
if self.order.status == Order.STATUS_PAID and self._totaldiff > 0: if self.order.status == Order.STATUS_PAID and totaldiff > 0:
if self.order.pending_sum > Decimal('0.00'): if self.order.pending_sum > Decimal('0.00'):
self.order.status = Order.STATUS_PENDING self.order.status = Order.STATUS_PENDING
self.order.set_expires( self.order.set_expires(
@@ -2219,7 +2234,7 @@ class OrderChangeManager:
self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True)) self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True))
) )
self.order.save() self.order.save()
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0: elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff < 0:
if self.order.pending_sum <= Decimal('0.00') and not self.order.require_approval: if self.order.pending_sum <= Decimal('0.00') and not self.order.require_approval:
self.order.status = Order.STATUS_PAID self.order.status = Order.STATUS_PAID
self.order.save() self.order.save()
@@ -2246,7 +2261,7 @@ class OrderChangeManager:
user=self.user, user=self.user,
auth=self.auth auth=self.auth
) )
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0: elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff > 0:
if self.open_payment: if self.open_payment:
try: try:
self.open_payment.payment_provider.cancel_payment(self.open_payment) self.open_payment.payment_provider.cancel_payment(self.open_payment)
@@ -2266,11 +2281,11 @@ class OrderChangeManager:
auth=self.auth, auth=self.auth,
) )
def _check_paid_to_free(self): def _check_paid_to_free(self, totaldiff):
if self.event.currency == 'XXX' and self.order.total + self._totaldiff > Decimal("0.00"): if self.event.currency == 'XXX' and self.order.total + totaldiff > Decimal("0.00"):
raise OrderError(error_messages['currency_XXX']) raise OrderError(error_messages['currency_XXX'])
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval: if self.order.total == 0 and (totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
if not self.order.fees.exists() and not self.order.positions.exists(): if not self.order.fees.exists() and not self.order.positions.exists():
# The order is completely empty now, so we cancel it. # The order is completely empty now, so we cancel it.
self.order.status = Order.STATUS_CANCELED self.order.status = Order.STATUS_CANCELED
@@ -2278,7 +2293,7 @@ class OrderChangeManager:
order_canceled.send(self.order.event, order=self.order) order_canceled.send(self.order.event, order=self.order)
elif self.order.status != Order.STATUS_CANCELED: elif self.order.status != Order.STATUS_CANCELED:
# if the order becomes free, mark it paid using the 'free' provider # if the order becomes free, mark it paid using the 'free' provider
# this could happen if positions have been made cheaper or removed (_totaldiff < 0) # this could happen if positions have been made cheaper or removed (totaldiff < 0)
# or positions got split off to a new order (split_order with positive total) # or positions got split off to a new order (split_order with positive total)
p = self.order.payments.create( p = self.order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED, state=OrderPayment.PAYMENT_STATE_CREATED,
@@ -2407,10 +2422,15 @@ class OrderChangeManager:
'new_price': op.price.gross 'new_price': op.price.gross
}) })
position.price = op.price.gross position.price = op.price.gross
position.price_includes_rounding_correction = Decimal("0.00")
position.tax_rate = op.price.rate position.tax_rate = op.price.rate
position.tax_value = op.price.tax position.tax_value = op.price.tax
position.tax_value_includes_rounding_correction = Decimal("0.00")
position.tax_code = op.price.code position.tax_code = op.price.code
position.save(update_fields=['price', 'tax_rate', 'tax_value', 'tax_code']) position.save(update_fields=[
'price', 'price_includes_rounding_correction', 'tax_rate', 'tax_value',
'tax_value_includes_rounding_correction', 'tax_code'
])
elif isinstance(op, self.TaxRuleOperation): elif isinstance(op, self.TaxRuleOperation):
if isinstance(op.position, OrderPosition): if isinstance(op.position, OrderPosition):
position = position_cache.setdefault(op.position.pk, op.position) position = position_cache.setdefault(op.position.pk, op.position)
@@ -2677,14 +2697,18 @@ class OrderChangeManager:
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
pass pass
split_order.total = sum([p.price for p in split_positions if not p.canceled]) fees = []
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT): for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
new_fee = modelcopy(fee) new_fee = modelcopy(fee)
new_fee.pk = None new_fee.pk = None
new_fee.order = split_order new_fee.order = split_order
split_order.total += new_fee.value
new_fee.save() new_fee.save()
fees.append(new_fee)
changed_by_rounding = set(apply_rounding(
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
))
split_order.total = sum([p.price for p in split_positions if not p.canceled])
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID: if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
pp = self._get_payment_provider() pp = self._get_payment_provider()
@@ -2697,9 +2721,27 @@ class OrderChangeManager:
fee._calculate_tax() fee._calculate_tax()
if payment_fee != 0: if payment_fee != 0:
fee.save() fee.save()
fees.append(fee)
elif fee.pk: elif fee.pk:
if fee in fees:
fees.remove(fee)
fee.delete() fee.delete()
split_order.total += fee.value
changed_by_rounding |= set(apply_rounding(
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
))
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
for l in changed_by_rounding:
if isinstance(l, OrderPosition):
l.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(l, OrderFee):
l.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()]) remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total) offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total)
@@ -2759,9 +2801,12 @@ class OrderChangeManager:
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
return payment_sum - refund_sum return payment_sum - refund_sum
def _recalculate_total_and_payment_fee(self): def _recalculate_rounding_total_and_payment_fee(self):
total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()]) positions = list(self.order.positions.all())
fees = list(self.order.fees.all())
total = sum([p.price for p in positions]) + sum([f.value for f in fees])
payment_fee = Decimal('0.00') payment_fee = Decimal('0.00')
fee_changed = False
if self.open_payment: if self.open_payment:
current_fee = Decimal('0.00') current_fee = Decimal('0.00')
fee = None fee = None
@@ -2789,14 +2834,32 @@ class OrderChangeManager:
fee.value = payment_fee fee.value = payment_fee
fee._calculate_tax() fee._calculate_tax()
fee.save() fee.save()
fee_changed = True
if not self.open_payment.fee: if not self.open_payment.fee:
self.open_payment.fee = fee self.open_payment.fee = fee
self.open_payment.save(update_fields=['fee']) self.open_payment.save(update_fields=['fee'])
elif fee and not fee.canceled: elif fee and not fee.canceled:
fee.delete() fee.delete()
fee_changed = True
self.order.total = total + payment_fee if fee_changed:
fees = list(self.order.fees.all())
changed = apply_rounding(self.order.tax_rounding_mode, self.order.event.currency, [*positions, *fees])
for l in changed:
if isinstance(l, OrderPosition):
l.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(l, OrderFee):
l.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
total = sum([p.price for p in positions]) + sum([f.value for f in fees])
self.order.total = total
self.order.save() self.order.save()
return total
def _check_order_size(self): def _check_order_size(self):
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
@@ -2806,23 +2869,6 @@ class OrderChangeManager:
} }
) )
def _payment_fee_diff(self):
total = self.order.total + self._totaldiff
if self.open_payment:
current_fee = Decimal('0.00')
if self.open_payment and self.open_payment.fee:
current_fee = self.open_payment.fee.value
total -= current_fee
# Do not change payment fees of paid orders
payment_fee = Decimal('0.00')
if self.order.pending_sum - current_fee != 0:
prov = self.open_payment.payment_provider
if prov:
payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
self._totaldiff += payment_fee - current_fee
def _reissue_invoice(self): def _reissue_invoice(self):
i = self.order.invoices.filter(is_cancellation=False).last() i = self.order.invoices.filter(is_cancellation=False).last()
if self.reissue_invoice and self._invoice_dirty: if self.reissue_invoice and self._invoice_dirty:
@@ -2953,6 +2999,13 @@ class OrderChangeManager:
shared_lock_objects=[self.event] shared_lock_objects=[self.event]
) )
def guess_totaldiff(self):
"""
Return the estimated difference of ``order.total`` based on the currently queued operations. This is only
a guess since it does not account for (a) tax rounding or (b) payment fee changes.
"""
return self._totaldiff_guesstimate
def commit(self, check_quotas=True): def commit(self, check_quotas=True):
if self._committed: if self._committed:
# an order change can only be committed once # an order change can only be committed once
@@ -2968,8 +3021,6 @@ class OrderChangeManager:
# so it's dangerous to keep the cache around. # so it's dangerous to keep the cache around.
self.order._prefetched_objects_cache = {} self.order._prefetched_objects_cache = {}
# finally, incorporate difference in payment fees
self._payment_fee_diff()
self._check_order_size() self._check_order_size()
with transaction.atomic(): with transaction.atomic():
@@ -2977,6 +3028,7 @@ class OrderChangeManager:
if locked_instance.last_modified != self.order.last_modified: if locked_instance.last_modified != self.order.last_modified:
raise OrderError(error_messages['race_condition']) raise OrderError(error_messages['race_condition'])
original_total = self.order.total
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID): if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
if check_quotas: if check_quotas:
self._check_quotas() self._check_quotas()
@@ -2988,9 +3040,10 @@ class OrderChangeManager:
self._perform_operations() self._perform_operations()
except TaxRule.SaleNotAllowed: except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked']) raise OrderError(self.error_messages['tax_rule_country_blocked'])
self._recalculate_total_and_payment_fee() new_total = self._recalculate_rounding_total_and_payment_fee()
self._check_paid_price_change() totaldiff = new_total - original_total
self._check_paid_to_free() self._check_paid_price_change(totaldiff)
self._check_paid_to_free(totaldiff)
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID): if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
self._reissue_invoice() self._reissue_invoice()
self._clear_tickets_cache() self._clear_tickets_cache()
@@ -3209,6 +3262,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
raise Exception('change_payment_provider should only be called in atomic transaction!') raise Exception('change_payment_provider should only be called in atomic transaction!')
oldtotal = order.total oldtotal = order.total
already_paid = order.payment_refund_sum
e = OrderPayment.objects.filter(fee=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, e = OrderPayment.objects.filter(fee=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED,
OrderPayment.PAYMENT_STATE_REFUNDED)) OrderPayment.PAYMENT_STATE_REFUNDED))
open_fees = list( open_fees = list(
@@ -3225,19 +3279,46 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
fee = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.00'), order=order) fee = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.00'), order=order)
old_fee = fee.value old_fee = fee.value
positions = list(order.positions.all())
fees = list(order.fees.all())
rounding_changed = set(apply_rounding(
order.tax_rounding_mode, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]]
))
total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk)
pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid)
new_fee = payment_provider.calculate_fee( new_fee = payment_provider.calculate_fee(
order.pending_sum - old_fee if amount is None else amount pending_sum_without_fee if amount is None else amount
) )
if new_fee: if new_fee:
fee.value = new_fee fee.value = new_fee
fee.internal_type = payment_provider.identifier fee.internal_type = payment_provider.identifier
fee._calculate_tax() fee._calculate_tax()
if fee in fees:
fees.remove(fee)
# "Update instance in the fees array
fees.append(fee)
fee.save() fee.save()
else: else:
if fee in fees:
fees.remove(fee)
if fee.pk: if fee.pk:
fee.delete() fee.delete()
fee = None fee = None
rounding_changed |= set(apply_rounding(
order.tax_rounding_mode, order.event.currency, [*positions, *fees]
))
for l in rounding_changed:
if isinstance(l, OrderPosition):
l.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(l, OrderFee):
l.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
open_payment = None open_payment = None
if new_payment: if new_payment:
lp = order.payments.select_for_update(of=OF_SELF).exclude(pk=new_payment.pk).last() lp = order.payments.select_for_update(of=OF_SELF).exclude(pk=new_payment.pk).last()
@@ -3264,7 +3345,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
}, },
) )
order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0) order.total = sum(c.price for c in positions) + sum(f.value for f in fees)
order.save(update_fields=['total']) order.save(update_fields=['total'])
if not new_payment: if not new_payment:
+128 -6
View File
@@ -23,15 +23,17 @@ import re
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import List, Optional, Tuple, Union from itertools import groupby
from typing import List, Literal, Optional, Tuple, Union
from django import forms from django import forms
from django.conf import settings
from django.db.models import Q from django.db.models import Q
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.models import ( from pretix.base.models import (
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
SalesChannel, Voucher, ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
) )
from pretix.base.models.discount import Discount, PositionInfo from pretix.base.models.discount import Discount, PositionInfo
from pretix.base.models.event import Event, SubEvent from pretix.base.models.event import Event, SubEvent
@@ -136,7 +138,8 @@ def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubE
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool, def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal, is_bundled=False) -> TaxedPrice: tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal,
is_bundled=False) -> TaxedPrice:
if not tax_rule: if not tax_rule:
tax_rule = TaxRule( tax_rule = TaxRule(
name='', name='',
@@ -152,7 +155,8 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
override_tax_code=price.code, invoice_address=invoice_address, override_tax_code=price.code, invoice_address=invoice_address,
subtract_from_gross=bundled_sum) subtract_from_gross=bundled_sum)
else: else:
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate, price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross',
override_tax_rate=price.rate,
override_tax_code=price.code, invoice_address=invoice_address, override_tax_code=price.code, invoice_address=invoice_address,
subtract_from_gross=bundled_sum) subtract_from_gross=bundled_sum)
else: else:
@@ -164,7 +168,7 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel], def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]], positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]: collect_potential_discounts: Optional[defaultdict] = None) -> List[Tuple[Decimal, Optional[Discount]]]:
""" """
Applies any dynamic discounts to a cart Applies any dynamic discounts to a cart
@@ -203,3 +207,121 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
new_prices.update(result) new_prices.update(result)
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)] return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep_gross"], currency: str,
lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
"""
Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode
and mutates the ``price``, ``price_includes_rounding_correction``, ``tax_value``, and
``tax_value_includes_rounding_correction`` attributes.
When rounding mode is set to ``"line"``, the tax will be computed and rounded individually for every line.
When rounding mode is set to ``"sum_by_net_keep_gross"``, the tax values of the individual lines will be adjusted
such that the per-taxrate/taxcode subtotal is rounded correctly. The gross prices will stay constant.
When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be
adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant.
:param rounding_mode: One of ``"line"``, ``"sum_by_net"``, or ``"sum_by_net_keep_gross"``.
:param currency: Currency that will be used to determine rounding precision
:param lines: List of order/cart contents
:return: Collection of ``lines`` members that have been changed and may need to be persisted to the database.
"""
def _key(line):
return (line.tax_rate, line.tax_code)
places = settings.CURRENCY_PLACES.get(currency, 2)
minimum_unit = Decimal('1') / 10 ** places
changed = []
if rounding_mode == "sum_by_net":
for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding))
# Compute the net and gross total of the line-based computation method
net_total = sum(l.net_price_before_rounding for l in lines)
gross_total = sum(l.gross_price_before_rounding for l in lines)
# Compute the gross total we need to achieve based on the net total
target_gross_total = round_decimal((net_total * (1 + tax_rate / 100)), currency)
# Add/subtract the smallest possible from both gross prices and tax values (so net values stay the same)
# until the values align
diff = target_gross_total - gross_total
diff_sgn = -1 if diff < 0 else 1
for l in lines:
if diff:
apply_diff = diff_sgn * minimum_unit
l.price = l.gross_price_before_rounding + apply_diff
l.price_includes_rounding_correction = apply_diff
l.tax_value = l.tax_value_before_rounding + apply_diff
l.tax_value_includes_rounding_correction = apply_diff
diff -= apply_diff
changed.append(l)
elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
l.price = l.gross_price_before_rounding
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value_before_rounding
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
elif rounding_mode == "sum_by_net_keep_gross":
for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding))
# Compute the net and gross total of the line-based computation method
net_total = sum(l.net_price_before_rounding for l in lines)
gross_total = sum(l.gross_price_before_rounding for l in lines)
# Compute the net total that would yield the correct gross total (if possible)
target_net_total = round_decimal(gross_total - (gross_total * (1 - 100 / (100 + tax_rate))), currency)
# Compute the gross total that would be computed from that net total this will be different than
# gross_total when there is no possible net value for the gross total
# e.g. 99.99 at 19% is impossible since 84.03 + 19% = 100.00 and 84.02 + 19% = 99.98
target_gross_total = round_decimal((target_net_total * (1 + tax_rate / 100)), currency)
diff_gross = target_gross_total - gross_total
diff_net = target_net_total - net_total
diff_gross_sgn = -1 if diff_gross < 0 else 1
diff_net_sgn = -1 if diff_net < 0 else 1
for l in lines:
if diff_gross:
apply_diff = diff_gross_sgn * minimum_unit
l.price = l.gross_price_before_rounding + apply_diff
l.price_includes_rounding_correction = apply_diff
l.tax_value = l.tax_value_before_rounding + apply_diff
l.tax_value_includes_rounding_correction = apply_diff
changed.append(l)
diff_gross -= apply_diff
elif diff_net:
apply_diff = diff_net_sgn * minimum_unit
l.price = l.gross_price_before_rounding
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value_before_rounding - apply_diff
l.tax_value_includes_rounding_correction = -apply_diff
changed.append(l)
diff_net -= apply_diff
elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
l.price = l.gross_price_before_rounding
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value_before_rounding
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
elif rounding_mode == "line":
for l in lines:
if l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
l.price = l.gross_price_before_rounding
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value_before_rounding
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
else:
raise ValueError("Unknown rounding_mode")
return changed
+27 -1
View File
@@ -77,6 +77,13 @@ from pretix.control.forms import (
) )
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
ROUNDING_MODES = (
('line', _('Compute taxes for every line individually')),
('sum_by_net', _('Compute taxes based on net total')),
('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')),
# We could also have sum_by_gross, but we're not aware of any use-cases for it
)
def country_choice_kwargs(): def country_choice_kwargs():
allcountries = list(CachedCountries()) allcountries = list(CachedCountries())
@@ -324,7 +331,7 @@ DEFAULTS = {
'form_class': forms.BooleanField, 'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField, 'serializer_class': serializers.BooleanField,
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Show net prices instead of gross prices in the product list (not recommended!)"), label=_("Show net prices instead of gross prices in the product list"),
help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be " help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be "
"paid."), "paid."),
@@ -465,6 +472,25 @@ DEFAULTS = {
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}), widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}),
) )
}, },
'tax_rounding': {
'default': 'line',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'form_kwargs': dict(
label=_("Rounding of taxes"),
widget=forms.RadioSelect,
choices=ROUNDING_MODES,
help_text=_(
"Note that if you transfer your sales data from pretix to an external system for tax reporting, you "
"need to make sure to account for possible rounding differences if your external system rounds "
"differently than pretix."
)
),
'serializer_kwargs': dict(
choices=ROUNDING_MODES,
),
},
'invoice_address_asked': { 'invoice_address_asked': {
'default': 'True', 'default': 'True',
'type': bool, 'type': bool,
+79 -3
View File
@@ -68,7 +68,7 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.services.placeholders import FormPlaceholderMixin from pretix.base.services.placeholders import FormPlaceholderMixin
from pretix.base.settings import ( from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES, COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES,
PERSON_NAME_TITLE_GROUPS, validate_event_settings, PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES, validate_event_settings,
) )
from pretix.base.validators import multimail_validate from pretix.base.validators import multimail_validate
from pretix.control.forms import ( from pretix.control.forms import (
@@ -541,7 +541,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'show_date_to', 'show_date_to',
'show_times', 'show_times',
'show_items_outside_presale_period', 'show_items_outside_presale_period',
'display_net_prices',
'hide_prices_from_attendees', 'hide_prices_from_attendees',
'presale_start_show_date', 'presale_start_show_date',
'locales', 'locales',
@@ -799,6 +798,80 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
return value return value
class DisplayNetPricesBooleanSelect(forms.RadioSelect):
def __init__(self, attrs=None):
choices = (
("false", format_html(
'{} <br><span class="text-muted">{}</span>',
_("Prices including tax"),
_("Recommended if you sell tickets at least partly to consumers.")
)),
("true", format_html(
'{} <br><span class="text-muted">{}</span>',
_("Prices excluding tax"),
_("Recommended only if you sell tickets primarily to business customers.")
)),
)
super().__init__(attrs, choices)
def format_value(self, value):
try:
return {
True: "true",
False: "false",
"true": "true",
"false": "false",
}[value]
except KeyError:
return "unknown"
def value_from_datadict(self, data, files, name):
value = data.get(name)
return {
True: True,
"True": True,
"False": False,
False: False,
"true": True,
"false": False,
}.get(value)
class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
auto_fields = [
'display_net_prices',
'tax_rounding',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["display_net_prices"].label = _("Prices shown to customer")
self.fields["display_net_prices"].widget = DisplayNetPricesBooleanSelect()
help_text = {
"line": _(
"Recommended when e-invoicing is not required. Each product will be sold with the advertised "
"net and gross price. However, in orders of more than one product, the total tax amount "
"can differ from when it would be computed from the order total."
),
"sum_by_net": _(
"Recommended for e-invoicing when you primarily sell to business customers and "
"show prices to customers excluding tax. "
"The gross price of some products may be changed to ensure correct rounding, while the net "
"prices will be kept as configured. This may cause the actual payment amount to differ."
),
"sum_by_net_keep_gross": _(
"Recommended for e-invoicing when you primarily sell to consumers. "
"The gross or net price of some products may be changed automatically to ensure correct "
"rounding of the order total. The system attempts to keep gross prices as configured whenever "
"possible. Gross prices may still change if they are impossible to derive from a rounded net price."
),
}
self.fields["tax_rounding"].choices = (
(k, format_html('{}<br><span class="text-muted">{}</span>', v, help_text.get(k, "")))
for k, v in ROUNDING_MODES
)
class ProviderForm(SettingsForm): class ProviderForm(SettingsForm):
""" """
This is a SettingsForm, but if fields are set to required=True, validation This is a SettingsForm, but if fields are set to required=True, validation
@@ -1527,7 +1600,10 @@ class TaxRuleLineForm(I18nForm):
rate = forms.DecimalField( rate = forms.DecimalField(
label=_('Deviating tax rate'), label=_('Deviating tax rate'),
max_digits=10, decimal_places=2, max_digits=10, decimal_places=2,
required=False required=False,
widget=forms.NumberInput(attrs={
'placeholder': _('Deviating tax rate'),
})
) )
invoice_text = I18nFormField( invoice_text = I18nFormField(
label=_('Text on invoice'), label=_('Text on invoice'),
+50 -1
View File
@@ -56,7 +56,8 @@ from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm
from pretix.base.forms.widgets import DatePickerWidget from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import ( from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota, Item, ItemCategory, ItemProgramTime, ItemVariation, Question,
QuestionOption, Quota,
) )
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data from pretix.base.signals import item_copy_data
@@ -572,6 +573,8 @@ class ItemCreateForm(I18nModelForm):
for b in self.cleaned_data['copy_from'].bundles.all(): for b in self.cleaned_data['copy_from'].bundles.all():
instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation, instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation,
count=b.count, designated_price=b.designated_price) count=b.count, designated_price=b.designated_price)
for pt in self.cleaned_data['copy_from'].program_times.all():
instance.program_times.create(start=pt.start, end=pt.end)
item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance) item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance)
@@ -1321,3 +1324,49 @@ class ItemMetaValueForm(forms.ModelForm):
widgets = { widgets = {
'value': forms.TextInput() 'value': forms.TextInput()
} }
class ItemProgramTimeFormSet(I18nFormSet):
template = "pretixcontrol/item/include_program_times.html"
title = _('Program times')
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
return super()._construct_form(i, **kwargs)
@property
def empty_form(self):
self.is_valid()
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
use_required_attribute=False,
locales=self.locales,
event=self.event
)
self.add_fields(form, None)
return form
class ItemProgramTimeForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['end'].widget.attrs['data-date-after'] = '#id_{prefix}-start_0'.format(prefix=self.prefix)
class Meta:
model = ItemProgramTime
localized_fields = '__all__'
fields = [
'start',
'end',
]
field_classes = {
'start': forms.SplitDateTimeField,
'end': forms.SplitDateTimeField,
}
widgets = {
'start': SplitDateTimePickerWidget(),
'end': SplitDateTimePickerWidget(),
}
+1
View File
@@ -69,6 +69,7 @@ class UserEditForm(forms.ModelForm):
'email', 'email',
'require_2fa', 'require_2fa',
'is_active', 'is_active',
'is_verified',
'is_staff', 'is_staff',
'needs_password_change', 'needs_password_change',
'last_login' 'last_login'
+11
View File
@@ -667,6 +667,14 @@ class UserSettingsChangedLogEntryType(LogEntryType):
return text return text
@log_entry_types.new_from_dict({
'pretix.user.email.changed': _('Your email address has been changed from {old_email} to {email}.'),
'pretix.user.email.confirmed': _('Your email address {email} has been confirmed.'),
})
class UserEmailChangedLogEntryType(LogEntryType):
pass
class UserImpersonatedLogEntryType(LogEntryType): class UserImpersonatedLogEntryType(LogEntryType):
def display(self, logentry, data): def display(self, logentry, data):
return self.plain.format(data['other_email']) return self.plain.format(data['other_email'])
@@ -882,6 +890,9 @@ class EventPluginStateLogEntryType(EventLogEntryType):
'pretix.event.item.bundles.added': _('A bundled item has been added to this product.'), 'pretix.event.item.bundles.added': _('A bundled item has been added to this product.'),
'pretix.event.item.bundles.removed': _('A bundled item has been removed from this product.'), 'pretix.event.item.bundles.removed': _('A bundled item has been removed from this product.'),
'pretix.event.item.bundles.changed': _('A bundled item has been changed on this product.'), 'pretix.event.item.bundles.changed': _('A bundled item has been changed on this product.'),
'pretix.event.item.program_times.added': _('A program time has been added to this product.'),
'pretix.event.item.program_times.changed': _('A program time has been changed on this product.'),
'pretix.event.item.program_times.removed': _('A program time has been removed from this product.'),
}) })
class CoreItemLogEntryType(ItemLogEntryType): class CoreItemLogEntryType(ItemLogEntryType):
pass pass
+2 -2
View File
@@ -72,7 +72,7 @@ class PermissionMiddleware:
) )
EXCEPTIONS_FORCED_PW_CHANGE = ( EXCEPTIONS_FORCED_PW_CHANGE = (
"user.settings", "user.settings.password.change",
"auth.logout" "auth.logout"
) )
@@ -139,7 +139,7 @@ class PermissionMiddleware:
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path())) return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
except SessionPasswordChangeRequired: except SessionPasswordChangeRequired:
if url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE: if url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path())) return redirect_to_url(reverse('control:user.settings.password.change') + '?next=' + quote(request.get_full_path()))
except Session2FASetupRequired: except Session2FASetupRequired:
if url_name not in self.EXCEPTIONS_2FA: if url_name not in self.EXCEPTIONS_2FA:
return redirect_to_url(reverse('control:user.settings.2fa')) return redirect_to_url(reverse('control:user.settings.2fa'))
+1 -1
View File
@@ -86,7 +86,7 @@ def get_event_navigation(request: HttpRequest):
'active': url.url_name == 'event.settings.mail', 'active': url.url_name == 'event.settings.mail',
}, },
{ {
'label': _('Tax rules'), 'label': _('Taxes'),
'url': reverse('control:event.settings.tax', kwargs={ 'url': reverse('control:event.settings.tax', kwargs={
'event': request.event.slug, 'event': request.event.slug,
'organizer': request.event.organizer.slug, 'organizer': request.event.organizer.slug,
@@ -7,6 +7,7 @@
<h3>{% trans "Set new password" %}</h3> <h3>{% trans "Set new password" %}</h3>
{% csrf_token %} {% csrf_token %}
{% bootstrap_form_errors form type='all' layout='inline' %} {% bootstrap_form_errors form type='all' layout='inline' %}
{% bootstrap_field form.email %}
{% bootstrap_field form.password %} {% bootstrap_field form.password %}
{% bootstrap_field form.password_repeat %} {% bootstrap_field form.password_repeat %}
<div class="form-group buttons"> <div class="form-group buttons">
@@ -0,0 +1,13 @@
{% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello,
{{ reason }}
{{ code }}
Please do never give this code to another person. Our support team will never ask for this code.
If this code was not requested by you, please contact us immediately.
Best regards,
Your pretix team
{% endblocktrans %}
@@ -243,7 +243,6 @@
{% bootstrap_field sform.show_times layout="control" %} {% bootstrap_field sform.show_times layout="control" %}
<h4>{% trans "Product list" %}</h4> <h4>{% trans "Product list" %}</h4>
{% bootstrap_field sform.show_quota_left layout="control" %} {% bootstrap_field sform.show_quota_left layout="control" %}
{% bootstrap_field sform.display_net_prices layout="control" %}
{% bootstrap_field sform.show_variations_expanded layout="control" %} {% bootstrap_field sform.show_variations_expanded layout="control" %}
{% bootstrap_field sform.hide_sold_out layout="control" %} {% bootstrap_field sform.hide_sold_out layout="control" %}
@@ -0,0 +1,120 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Taxes" %}{% endblock %}
{% block inside %}
<h1>{% trans "Taxes" %}</h1>
{% bootstrap_form_errors form layout="control" %}
<fieldset>
<legend>{% trans "Tax rules" %}</legend>
<p>
{% blocktrans trimmed %}
Tax rules define different taxation scenarios that can then be assigned to the individual products.
Each tax rule contains a default tax rate and can optionally contain additional rules that depend
on the customer's country and type.
{% endblocktrans %}
</p>
{% if taxrules|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any tax rules yet.
{% endblocktrans %}
</p>
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}</a>
</div>
{% else %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Default" %}</th>
<th>{% trans "Usage" %}</th>
<th>{% trans "Rate" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for tr in taxrules %}
<tr>
<td>
<strong><a
href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{{ tr.internal_name|default:tr.name }}
</a></strong>
</td>
<td>
{% if tr.default %}
<span class="text-success">
<span class="fa fa-check"></span>
{% trans "Default" %}
</span>
{% else %}
<form class="form-inline" method="post"
action="{% url "control:event.settings.tax.default" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{% csrf_token %}
<button class="btn btn-default btn-sm">
{% trans "Make default" %}
</button>
</form>
{% endif %}
</td>
<td>
{% blocktrans trimmed count count=tr.c_items %}
{{ count }} product
{% plural %}
{{ count }} products
{% endblocktrans %}
</td>
<td>
{% if tr.price_includes_tax %}
{% blocktrans with rate=tr.rate %}incl. {{ rate }} %{% endblocktrans %}
{% else %}
{% blocktrans with rate=tr.rate %}excl. {{ rate }} %{% endblocktrans %}
{% endif %}
{% if tr.has_custom_rules %}
<br><small>{% trans "with custom rules" %}</small>
{% elif tr.eu_reverse_charge %}
<br><small>{% trans "reverse charge enabled" %}</small>
{% endif %}
</td>
<td class="text-right flip">
<a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.settings.tax.delete" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="5">
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}
</a>
</td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
</fieldset>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<fieldset>
<legend>{% trans "Tax settings" %}</legend>
{% bootstrap_field form.tax_rounding layout="control" %}
{% bootstrap_field form.display_net_prices layout="control" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -1,77 +0,0 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% block title %}{% trans "Tax rules" %}{% endblock %}
{% block inside %}
<h1>{% trans "Tax rules" %}</h1>
{% if taxrules|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any tax rules yet.
{% endblocktrans %}
</p>
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}</a>
</div>
{% else %}
<p>
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}
</a>
</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Default" %}</th>
<th>{% trans "Rate" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for tr in taxrules %}
<tr>
<td>
<strong><a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{{ tr.internal_name|default:tr.name }}
</a></strong>
</td>
<td>
{% if tr.default %}
<span class="text-success">
<span class="fa fa-check"></span>
{% trans "Default" %}
</span>
{% else %}
<form class="form-inline" method="post"
action="{% url "control:event.settings.tax.default" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{% csrf_token %}
<button class="btn btn-default btn-sm">
{% trans "Make default" %}
</button>
</form>
{% endif %}
</td>
<td>
{% if tr.price_includes_tax %}
{% blocktrans with rate=tr.rate%}incl. {{ rate }} %{% endblocktrans %}
{% else %}
{% blocktrans with rate=tr.rate%}excl. {{ rate }} %{% endblocktrans %}
{% endif %}
{% if tr.eu_reverse_charge %}
({% trans "reverse charge enabled" %})
{% endif %}
</td>
<td class="text-right flip">
<a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.settings.tax.delete" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% include "pretixcontrol/pagination.html" %}
{% endblock %}
@@ -1,35 +0,0 @@
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% load rich_text %}
{{ request.event.settings.payment_giftcard_public_description|rich_text }}
{% if customer_gift_cards %}
<p><strong>
<span class="sr-only">{% trans "Information" %}</span>
{% trans "The following gift cards are available in your customer account:" %}
</strong></p>
<form method="post">
{% csrf_token %}
<ul class="list-group">
{% for c in customer_gift_cards %}
<li class="list-group-item row row-no-gutters">
<div class="col-sm-8 col-md-9" id="gc-code-{{ forloop.counter }}">
{{ c }}
</div>
<div class="col-sm-2 text-right" id="gc-value-{{ forloop.counter }}">
{{ c.value|money:c.currency }}
</div>
<div class="col-sm-2 col-md-1 text-right">
<button name="use_giftcard" class="btn btn-primary btn-xs use_giftcard" data-value="{{ c.secret }}"
title="{% trans "Use gift card" %}"
aria-describedby="gc-code-{{ forloop.counter }} gc-value-{{ forloop.counter }}">
{% trans "Apply" %}
</button>
</div>
</li>
{% endfor %}
</ul>
</form>
{% endif %}
{% bootstrap_form form layout='checkout' %}
@@ -0,0 +1,70 @@
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
<p>
{% blocktrans trimmed %}
With program times, you can set specific dates and times for this product.
This is useful if this product represents access to parts of your event that happen at different times than your event in general.
This will not affect access control, but will affect calendar invites and ticket output.
{% endblocktrans %}
</p>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<div class="row">
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Program time" %}</h3>
</div>
<div class="col-sm-4 text-right flip">
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_form_errors form %}
{% bootstrap_field form.start layout="control" %}
{% bootstrap_field form.end layout="control" %}
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<div class="row">
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Program time" %}</h3>
</div>
<div class="col-sm-4 text-right flip">
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.start layout="control" %}
{% bootstrap_field formset.empty_form.end layout="control" %}
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a program time" %}</button>
</p>
</div>
@@ -681,6 +681,16 @@
</small> </small>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if django_settings.DEBUG %}
<br/>
<small class="admin-only">
price = {{ line.price|floatformat:2 }}<br>
rounding_correction = {{ line.price_includes_rounding_correction|floatformat:2 }}<br>
tax_value = {{ line.tax_value|floatformat:2 }}<br>
tax_value_rounding_correction = {{ line.tax_value_includes_rounding_correction|floatformat:2 }}<br>
voucher_budget_use = {{ line.voucher_budget_use|floatformat:2 }}
</small>
{% endif %}
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>
@@ -721,6 +731,16 @@
</small> </small>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if django_settings.DEBUG %}
<br/>
<small class="admin-only">
price = {{ fee.value|floatformat:2 }}<br>
rounding_correction = {{ fee.value_includes_rounding_correction|floatformat:2 }}<br>
tax_value = {{ fee.tax_value|floatformat:2 }}<br>
tax_value_rounding_correction = {{ fee.tax_value_includes_rounding_correction|floatformat:2 }}<br>
voucher_budget_use = {{ fee.voucher_budget_use|floatformat:2 }}
</small>
{% endif %}
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>
@@ -749,6 +769,12 @@
</div> </div>
<div class="col-md-3 col-xs-6 col-md-offset-5 price"> <div class="col-md-3 col-xs-6 col-md-offset-5 price">
<strong>{{ items.total|money:event.currency }}</strong> <strong>{{ items.total|money:event.currency }}</strong>
{% if django_settings.DEBUG %}
<br/>
<small class="admin-only">
tax_rounding_mode = {{ order.tax_rounding_mode }}
</small>
{% endif %}
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>
@@ -56,8 +56,26 @@
<td>{{ t.get_tax_code_display }}</td> <td>{{ t.get_tax_code_display }}</td>
<td class="text-right flip">{{ t.count }} &times;</td> <td class="text-right flip">{{ t.count }} &times;</td>
<td class="text-right flip">{{ t.price|money:request.event.currency }}</td> <td class="text-right flip">{{ t.price|money:request.event.currency }}</td>
<td class="text-right flip">{{ t.full_tax_value|money:request.event.currency }}</td> <td class="text-right flip">
<td class="text-right flip">{{ t.full_price|money:request.event.currency }}</td> {{ t.full_tax_value|money:request.event.currency }}
{% if t.full_price_includes_rounding_correction %}
<br><small>
{% blocktrans trimmed with amount=t.full_tax_value_includes_rounding_correction|money:request.event.currency %}
incl. {{ amount }} rounding correction
{% endblocktrans %}
</small>
{% endif %}
</td>
<td class="text-right flip">
{{ t.full_price|money:request.event.currency }}
{% if t.full_price_includes_rounding_correction %}
<br><small>
{% blocktrans trimmed with amount=t.full_price_includes_rounding_correction|money:request.event.currency %}
incl. {{ amount }} rounding correction
{% endblocktrans %}
</small>
{% endif %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -0,0 +1,29 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Change login email address" %}{% endblock %}
{% block content %}
<form action="" method="post" class="form centered-form">
<h1>
{% trans "Change login email address" %}
</h1>
{% csrf_token %}
{% bootstrap_form_errors form %}
<p class="text-muted">
{% trans "This changes the email address used to login to your account, as well as where we send email notifications." %}
</p>
{% bootstrap_field form.old_email %}
{% bootstrap_field form.new_email %}
<p>
{% trans "We will send a confirmation code to your new email address, which you need to enter in the next step to confirm the email address is correct." %}
</p>
<div class="form-group submit-group">
<a href="{% url "control:user.settings" %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-primary btn-save btn-lg">
{% trans "Continue" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Change password" %}{% endblock %}
{% block content %}
<form action="" method="post" class="form centered-form">
<h1>
{% trans "Change password" %}
</h1>
<br>
{% csrf_token %}
{% bootstrap_form_errors form %}
{% bootstrap_field form.email %}
{% bootstrap_field form.old_pw %}
{% bootstrap_field form.new_pw %}
{% bootstrap_field form.new_pw_repeat %}
<div class="form-group submit-group">
<a href="{% url "control:user.settings" %}" class="btn btn-default btn-cancel">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary btn-save btn-lg">
{% trans "Change password" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Enter confirmation code" %}{% endblock %}
{% block content %}
<form action="" method="post" class="form centered-form">
<h1>
{% trans "Enter confirmation code" %}
</h1>
{% csrf_token %}
{% bootstrap_form_errors form type='all' layout='inline' %}
<p>{{ message }}</p>
{% bootstrap_field form.code %}
<div class="form-group submit-group">
<a href="{{ cancel_url }}" class="btn btn-default btn-cancel">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary btn-save btn-lg">
{% trans "Continue" %}
</button>
</div>
</form>
{% endblock %}
@@ -3,8 +3,26 @@
{% load bootstrap3 %} {% load bootstrap3 %}
{% block title %}{% trans "Account settings" %}{% endblock %} {% block title %}{% trans "Account settings" %}{% endblock %}
{% block content %} {% block content %}
{% if not user.is_verified %}
<div class="alert alert-info">
<p>
{% blocktrans trimmed %}
Your email address is not confirmed yet. To secure your account, please confirm your email address using
a confirmation code we will send to your email address.
{% endblocktrans %}
</p>
<p>
<form action="{% url "control:user.settings.email.send_verification_code" %}" method="post" class="form-horizontal">
{% csrf_token %}
<button type="submit" class="btn btn-primary">
{% trans "Send confirmation email" %}
</button>
</form>
</p>
</div>
{% endif %}
<h1>{% trans "Account settings" %}</h1> <h1>{% trans "Account settings" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal" data-testid="usersettingsform">
{% csrf_token %} {% csrf_token %}
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
<fieldset> <fieldset>
@@ -13,7 +31,7 @@
{% bootstrap_field form.locale layout='horizontal' %} {% bootstrap_field form.locale layout='horizontal' %}
{% bootstrap_field form.timezone layout='horizontal' %} {% bootstrap_field form.timezone layout='horizontal' %}
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label" for="id_new_pw_repeat">{% trans "Notifications" %}</label> <label class="col-md-3 control-label">{% trans "Notifications" %}</label>
<div class="col-md-9 static-form-row"> <div class="col-md-9 static-form-row">
{% if request.user.notifications_send and request.user.notification_settings.exists %} {% if request.user.notifications_send and request.user.notification_settings.exists %}
<span class="label label-success"> <span class="label label-success">
@@ -41,8 +59,18 @@
{% bootstrap_field form.new_pw layout='horizontal' %} {% bootstrap_field form.new_pw layout='horizontal' %}
{% bootstrap_field form.new_pw_repeat layout='horizontal' %} {% bootstrap_field form.new_pw_repeat layout='horizontal' %}
{% endif %} {% endif %}
{% if user.auth_backend == 'native' %}
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Password" %}</label>
<div class="col-md-9 static-form-row">
<a href="{% url "control:user.settings.password.change" %}">
<span class="fa fa-edit"></span> {% trans "Change password" %}
</a>
</div>
</div>
{% endif %}
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label" for="id_new_pw_repeat">{% trans "Two-factor authentication" %}</label> <label class="col-md-3 control-label">{% trans "Two-factor authentication" %}</label>
<div class="col-md-9 static-form-row"> <div class="col-md-9 static-form-row">
{% if user.require_2fa %} {% if user.require_2fa %}
<span class="label label-success">{% trans "Enabled" %}</span> &nbsp; <span class="label label-success">{% trans "Enabled" %}</span> &nbsp;
@@ -58,7 +86,7 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label" for="">{% trans "Authorized applications" %}</label> <label class="col-md-3 control-label">{% trans "Authorized applications" %}</label>
<div class="col-md-9 static-form-row"> <div class="col-md-9 static-form-row">
<a href="{% url "control:user.settings.oauth.list" %}"> <a href="{% url "control:user.settings.oauth.list" %}">
<span class="fa fa-plug"></span> <span class="fa fa-plug"></span>
@@ -67,7 +95,7 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label" for="">{% trans "Account history" %}</label> <label class="col-md-3 control-label">{% trans "Account history" %}</label>
<div class="col-md-9 static-form-row"> <div class="col-md-9 static-form-row">
<a href="{% url "control:user.settings.history" %}"> <a href="{% url "control:user.settings.history" %}">
<span class="fa fa-history"></span> <span class="fa fa-history"></span>
@@ -56,6 +56,7 @@
{% if form.new_pw %} {% if form.new_pw %}
{% bootstrap_field form.new_pw layout='control' %} {% bootstrap_field form.new_pw layout='control' %}
{% bootstrap_field form.new_pw_repeat layout='control' %} {% bootstrap_field form.new_pw_repeat layout='control' %}
{% bootstrap_field form.is_verified layout='control' %}
{% endif %} {% endif %}
{% bootstrap_field form.last_login layout='control' %} {% bootstrap_field form.last_login layout='control' %}
{% bootstrap_field form.require_2fa layout='control' %} {% bootstrap_field form.require_2fa layout='control' %}
+5 -1
View File
@@ -110,6 +110,10 @@ urlpatterns = [
name='user.settings.2fa.confirm.webauthn'), name='user.settings.2fa.confirm.webauthn'),
re_path(r'^settings/2fa/(?P<devicetype>[^/]+)/(?P<device>[0-9]+)/delete', user.User2FADeviceDeleteView.as_view(), re_path(r'^settings/2fa/(?P<devicetype>[^/]+)/(?P<device>[0-9]+)/delete', user.User2FADeviceDeleteView.as_view(),
name='user.settings.2fa.delete'), name='user.settings.2fa.delete'),
re_path(r'^settings/email/confirm$', user.UserEmailConfirmView.as_view(), name='user.settings.email.confirm'),
re_path(r'^settings/email/change$', user.UserEmailChangeView.as_view(), name='user.settings.email.change'),
re_path(r'^settings/email/verify', user.UserEmailVerifyView.as_view(), name='user.settings.email.send_verification_code'),
re_path(r'^settings/password/change$', user.UserPasswordChangeView.as_view(), name='user.settings.password.change'),
re_path(r'^organizers/$', organizer.OrganizerList.as_view(), name='organizers'), re_path(r'^organizers/$', organizer.OrganizerList.as_view(), name='organizers'),
re_path(r'^organizers/add$', organizer.OrganizerCreate.as_view(), name='organizers.add'), re_path(r'^organizers/add$', organizer.OrganizerCreate.as_view(), name='organizers.add'),
re_path(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'), re_path(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'),
@@ -290,7 +294,7 @@ urlpatterns = [
re_path(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'), re_path(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'),
re_path(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'), re_path(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'),
re_path(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'), re_path(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'),
re_path(r'^settings/tax/$', event.TaxList.as_view(), name='event.settings.tax'), re_path(r'^settings/tax/$', event.TaxSettings.as_view(), name='event.settings.tax'),
re_path(r'^settings/tax/(?P<rule>\d+)/$', event.TaxUpdate.as_view(), name='event.settings.tax.edit'), re_path(r'^settings/tax/(?P<rule>\d+)/$', event.TaxUpdate.as_view(), name='event.settings.tax.edit'),
re_path(r'^settings/tax/add$', event.TaxCreate.as_view(), name='event.settings.tax.add'), re_path(r'^settings/tax/add$', event.TaxCreate.as_view(), name='event.settings.tax.add'),
re_path(r'^settings/tax/(?P<rule>\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'), re_path(r'^settings/tax/(?P<rule>\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'),
+21 -12
View File
@@ -54,7 +54,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.files import File from django.core.files import File
from django.db import transaction from django.db import transaction
from django.db.models import ProtectedError from django.db.models import Count, ProtectedError
from django.forms import inlineformset_factory from django.forms import inlineformset_factory
from django.http import ( from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed,
@@ -90,7 +90,7 @@ from pretix.control.forms.event import (
EventFooterLinkFormset, EventMetaValueForm, EventSettingsForm, EventFooterLinkFormset, EventMetaValueForm, EventSettingsForm,
EventUpdateForm, InvoiceSettingsForm, ItemMetaPropertyForm, EventUpdateForm, InvoiceSettingsForm, ItemMetaPropertyForm,
MailSettingsForm, PaymentSettingsForm, ProviderForm, QuickSetupForm, MailSettingsForm, PaymentSettingsForm, ProviderForm, QuickSetupForm,
QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet, QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet, TaxSettingsForm,
TicketSettingsForm, WidgetCodeForm, TicketSettingsForm, WidgetCodeForm,
) )
from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.permissions import EventPermissionRequiredMixin
@@ -648,6 +648,25 @@ class PaymentSettings(EventSettingsViewMixin, EventSettingsFormView):
return context return context
class TaxSettings(EventSettingsViewMixin, EventSettingsFormView):
template_name = 'pretixcontrol/event/tax.html'
form_class = TaxSettingsForm
permission = 'can_change_event_settings'
def get_success_url(self) -> str:
return reverse('control:event.settings.tax', kwargs={
'organizer': self.request.organizer.slug,
'event': self.request.event.slug,
})
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
context['taxrules'] = self.request.event.tax_rules.annotate(
c_items=Count("item")
).all()
return context
class InvoiceSettings(EventSettingsViewMixin, EventSettingsFormView): class InvoiceSettings(EventSettingsViewMixin, EventSettingsFormView):
model = Event model = Event
form_class = InvoiceSettingsForm form_class = InvoiceSettingsForm
@@ -1263,16 +1282,6 @@ class EventComment(EventPermissionRequiredMixin, View):
}) })
class TaxList(EventSettingsViewMixin, EventPermissionRequiredMixin, PaginationMixin, ListView):
model = TaxRule
context_object_name = 'taxrules'
template_name = 'pretixcontrol/event/tax_index.html'
permission = 'can_change_event_settings'
def get_queryset(self):
return self.request.event.tax_rules.all()
class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView): class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView):
model = TaxRule model = TaxRule
form_class = TaxRuleForm form_class = TaxRuleForm
+31 -8
View File
@@ -60,13 +60,14 @@ from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country from django_countries.fields import Country
from pretix.api.serializers.item import ( from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemVariationSerializer, ItemAddOnSerializer, ItemBundleSerializer, ItemProgramTimeSerializer,
ItemVariationSerializer,
) )
from pretix.base.forms import I18nFormSet from pretix.base.forms import I18nFormSet
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemVariation, Order, OrderPosition, CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, Order,
Question, QuestionAnswer, QuestionOption, Quota, SeatCategoryMapping, OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
Voucher, SeatCategoryMapping, Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
@@ -75,9 +76,9 @@ from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability from pretix.base.signals import quota_availability
from pretix.control.forms.item import ( from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm, CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemUpdateForm, ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
ItemVariationForm, ItemVariationsFormSet, QuestionForm, QuestionOptionForm, ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm,
QuotaForm, ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm,
) )
from pretix.control.permissions import ( from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required, EventPermissionRequiredMixin, event_permission_required,
@@ -1431,7 +1432,8 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
form.instance.position = i form.instance.position = i
setattr(form.instance, attr, self.get_object()) setattr(form.instance, attr, self.get_object())
created = not form.instance.pk created = not form.instance.pk
form.save() if form.has_changed():
form.save()
if form.has_changed() and any(a for a in form.changed_data if a != 'ORDER'): if form.has_changed() and any(a for a in form.changed_data if a != 'ORDER'):
change_data = {k: form.cleaned_data.get(k) for k in form.changed_data} change_data = {k: form.cleaned_data.get(k) for k in form.changed_data}
if key == 'variations': if key == 'variations':
@@ -1497,6 +1499,16 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
'bundles', 'bundles', 'base_item', order=False, 'bundles', 'bundles', 'base_item', order=False,
serializer=ItemBundleSerializer serializer=ItemBundleSerializer
) )
elif k == 'program_times':
self.save_formset(
'program_times', 'program_times', order=False,
serializer=ItemProgramTimeSerializer
)
if not change_data:
for f in v.forms:
if (f in v.deleted_forms and f.instance.pk) or f.has_changed():
invalidate_cache.apply_async(kwargs={'event': self.request.event.pk, 'item': self.object.pk})
break
else: else:
v.save() v.save()
@@ -1559,9 +1571,20 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
queryset=ItemBundle.objects.filter(base_item=self.get_object()), queryset=ItemBundle.objects.filter(base_item=self.get_object()),
event=self.request.event, item=self.item, prefix="bundles" event=self.request.event, item=self.item, prefix="bundles"
)), )),
('program_times', inlineformset_factory(
Item, ItemProgramTime,
form=ItemProgramTimeForm, formset=ItemProgramTimeFormSet,
can_order=False, can_delete=True, extra=0
)(
self.request.POST if self.request.method == "POST" else None,
queryset=ItemProgramTime.objects.filter(item=self.get_object()),
event=self.request.event, prefix="program_times"
)),
]) ])
if not self.object.has_variations: if not self.object.has_variations:
del f['variations'] del f['variations']
if self.item.event.has_subevents:
del f['program_times']
i = 0 i = 0
for rec, resp in item_formsets.send(sender=self.request.event, item=self.item, request=self.request): for rec, resp in item_formsets.send(sender=self.request.event, item=self.item, request=self.request):
+4 -1
View File
@@ -633,7 +633,9 @@ class OrderTransactions(OrderView):
ctx['sums'] = self.order.transactions.aggregate( ctx['sums'] = self.order.transactions.aggregate(
sum_count=Sum('count'), sum_count=Sum('count'),
full_price=Sum(F('count') * F('price')), full_price=Sum(F('count') * F('price')),
full_price_includes_rounding_correction=Sum(F('count') * F('price_includes_rounding_correction')),
full_tax_value=Sum(F('count') * F('tax_value')), full_tax_value=Sum(F('count') * F('tax_value')),
full_tax_value_includes_rounding_correction=Sum(F('count') * F('tax_value_includes_rounding_correction')),
) )
return ctx return ctx
@@ -2144,7 +2146,8 @@ class OrderChange(OrderView):
self.order, self.order,
user=self.request.user, user=self.request.user,
notify=notify, notify=notify,
reissue_invoice=self.other_form.cleaned_data['reissue_invoice'] if self.other_form.is_valid() else True reissue_invoice=self.other_form.cleaned_data['reissue_invoice'] if self.other_form.is_valid() else True,
allow_blocked_seats=True,
) )
form_valid = (self._process_add_fees(ocm) and form_valid = (self._process_add_fees(ocm) and
self._process_add_positions(ocm) and self._process_add_positions(ocm) and
+6 -1
View File
@@ -44,7 +44,9 @@ from pypdf.errors import PdfReadError
from reportlab.lib.units import mm from reportlab.lib.units import mm
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import CachedFile, InvoiceAddress, OrderPosition from pretix.base.models import (
CachedFile, InvoiceAddress, ItemProgramTime, OrderPosition,
)
from pretix.base.pdf import get_images, get_variables from pretix.base.pdf import get_images, get_variables
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.permissions import EventPermissionRequiredMixin
@@ -95,6 +97,9 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
description=_("Sample product description")) description=_("Sample product description"))
item2 = self.request.event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40')) item2 = self.request.event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40'))
ItemProgramTime.objects.create(start=now(), end=now(), item=item)
ItemProgramTime.objects.create(start=now(), end=now(), item=item2)
from pretix.base.models import Order from pretix.base.models import Order
order = self.request.event.orders.create(status=Order.STATUS_PENDING, datetime=now(), order = self.request.event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
email='sample@pretix.eu', email='sample@pretix.eu',
+164 -21
View File
@@ -44,11 +44,13 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import BadRequest, PermissionDenied
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -60,8 +62,11 @@ from django_scopes import scopes_disabled
from webauthn.helpers import generate_challenge, generate_user_handle from webauthn.helpers import generate_challenge, generate_user_handle
from pretix.base.auth import get_auth_backends from pretix.base.auth import get_auth_backends
from pretix.base.forms.auth import ReauthForm from pretix.base.forms.auth import ConfirmationCodeForm, ReauthForm
from pretix.base.forms.user import User2FADeviceAddForm, UserSettingsForm from pretix.base.forms.user import (
User2FADeviceAddForm, UserEmailChangeForm, UserPasswordChangeForm,
UserSettingsForm,
)
from pretix.base.models import ( from pretix.base.models import (
Event, LogEntry, NotificationSetting, U2FDevice, User, WebAuthnDevice, Event, LogEntry, NotificationSetting, U2FDevice, User, WebAuthnDevice,
) )
@@ -237,25 +242,7 @@ class UserSettings(UpdateView):
data = {} data = {}
for k in form.changed_data: for k in form.changed_data:
if k not in ('old_pw', 'new_pw_repeat'): data[k] = form.cleaned_data[k]
if 'new_pw' == k:
data['new_pw'] = True
else:
data[k] = form.cleaned_data[k]
msgs = []
if 'new_pw' in form.changed_data:
self.request.user.needs_password_change = False
msgs.append(_('Your password has been changed.'))
if 'email' in form.changed_data:
msgs.append(_('Your email address has been changed to {email}.').format(email=form.cleaned_data['email']))
if msgs:
self.request.user.send_security_notice(msgs, email=form.cleaned_data['email'])
if self._old_email != form.cleaned_data['email']:
self.request.user.send_security_notice(msgs, email=self._old_email)
sup = super().form_valid(form) sup = super().form_valid(form)
self.request.user.log_action('pretix.user.settings.changed', user=self.request.user, data=data) self.request.user.log_action('pretix.user.settings.changed', user=self.request.user, data=data)
@@ -834,3 +821,159 @@ class EditStaffSession(StaffMemberRequiredMixin, UpdateView):
return get_object_or_404(StaffSession, pk=self.kwargs['id']) return get_object_or_404(StaffSession, pk=self.kwargs['id'])
else: else:
return get_object_or_404(StaffSession, pk=self.kwargs['id'], user=self.request.user) return get_object_or_404(StaffSession, pk=self.kwargs['id'], user=self.request.user)
class UserPasswordChangeView(FormView):
max_time = 300
form_class = UserPasswordChangeForm
template_name = 'pretixcontrol/user/change_password.html'
def get_form_kwargs(self):
if self.request.user.auth_backend != 'native':
raise PermissionDenied
return {
**super().get_form_kwargs(),
"user": self.request.user,
}
def form_valid(self, form):
with transaction.atomic():
self.request.user.set_password(form.cleaned_data['new_pw'])
self.request.user.needs_password_change = False
self.request.user.save()
msgs = []
msgs.append(_('Your password has been changed.'))
self.request.user.send_security_notice(msgs)
self.request.user.log_action('pretix.user.settings.changed', user=self.request.user, data={'new_pw': True})
update_session_auth_hash(self.request, self.request.user)
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
def get_success_url(self):
if "next" in self.request.GET and url_has_allowed_host_and_scheme(self.request.GET.get("next"), allowed_hosts=None):
return self.request.GET.get("next")
return reverse('control:user.settings')
class UserEmailChangeView(RecentAuthenticationRequiredMixin, FormView):
max_time = 300
form_class = UserEmailChangeForm
template_name = 'pretixcontrol/user/change_email.html'
def get_form_kwargs(self):
if self.request.user.auth_backend != 'native':
raise PermissionDenied
return {
**super().get_form_kwargs(),
"user": self.request.user,
}
def get_initial(self):
return {
"old_email": self.request.user.email
}
def form_valid(self, form):
self.request.user.send_confirmation_code(
session=self.request.session,
reason='email_change',
email=form.cleaned_data['new_email'],
state=form.cleaned_data['new_email'],
)
self.request.session['email_confirmation_destination'] = form.cleaned_data['new_email']
return redirect(reverse('control:user.settings.email.confirm', kwargs={}) + '?reason=email_change')
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
class UserEmailVerifyView(View):
def post(self, request, *args, **kwargs):
if self.request.user.is_verified:
messages.success(self.request, _('Your email address was already verified.'))
return redirect(reverse('control:user.settings', kwargs={}))
self.request.user.send_confirmation_code(
session=self.request.session,
reason='email_verify',
email=self.request.user.email,
state=self.request.user.email,
)
self.request.session['email_confirmation_destination'] = self.request.user.email
return redirect(reverse('control:user.settings.email.confirm', kwargs={}) + '?reason=email_verify')
class UserEmailConfirmView(FormView):
form_class = ConfirmationCodeForm
template_name = 'pretixcontrol/user/confirmation_code_dialog.html'
def get_context_data(self, **kwargs):
return {
**super().get_context_data(**kwargs),
"cancel_url": reverse('control:user.settings', kwargs={}),
"message": format_html(
_("Please enter the confirmation code we sent to your email address <strong>{email}</strong>."),
email=self.request.session.get('email_confirmation_destination', ''),
),
}
@transaction.atomic()
def form_valid(self, form):
reason = self.request.GET['reason']
if reason not in ('email_change', 'email_verify'):
raise PermissionDenied
try:
new_email = self.request.user.check_confirmation_code(
session=self.request.session,
reason=reason,
code=form.cleaned_data['code'],
)
except PermissionDenied:
return self.form_invalid(form)
except BadRequest:
messages.error(self.request, _(
'We were unable to verify your confirmation code. Please try again.'
))
return redirect(reverse('control:user.settings', kwargs={}))
log_data = {
'email': new_email,
'email_verified': True,
}
if reason == 'email_change':
msgs = []
msgs.append(_('Your email address has been changed to {email}.').format(email=new_email))
log_data['old_email'] = old_email = self.request.user.email
self.request.user.send_security_notice(msgs, email=old_email)
self.request.user.send_security_notice(msgs, email=new_email)
log_action = 'pretix.user.email.changed'
else:
log_action = 'pretix.user.email.confirmed'
self.request.user.email = new_email
self.request.user.is_verified = True
self.request.user.save()
self.request.user.log_action(log_action, user=self.request.user, data=log_data)
update_session_auth_hash(self.request, self.request.user)
if reason == 'email_change':
messages.success(self.request, _('Your email address has been changed successfully.'))
else:
messages.success(self.request, _('Your email address has been confirmed successfully.'))
return redirect(reverse('control:user.settings', kwargs={}))
def form_invalid(self, form):
messages.error(self.request, _('The entered confirmation code is not correct. Please try again.'))
return super().form_invalid(form)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+54 -55
View File
@@ -8,10 +8,10 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-29 07:55+0000\n" "POT-Creation-Date: 2025-10-29 07:55+0000\n"
"PO-Revision-Date: 2025-10-28 17:00+0000\n" "PO-Revision-Date: 2025-10-31 17:00+0000\n"
"Last-Translator: Núria Masclans <nuriamasclansserrat@gmail.com>\n" "Last-Translator: Núria Masclans <nuriamasclansserrat@gmail.com>\n"
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-" "Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/"
"js/ca/>\n" "pretix-js/ca/>\n"
"Language: ca\n" "Language: ca\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@@ -651,6 +651,8 @@ msgid ""
"Your color has insufficient contrast to white. Accessibility of your site " "Your color has insufficient contrast to white. Accessibility of your site "
"will be impacted." "will be impacted."
msgstr "" msgstr ""
"El color no té prou contrast amb el blanc i pot afectar a l'accessibilitat "
"del lloc web."
#: pretix/static/pretixcontrol/js/ui/main.js:418 #: pretix/static/pretixcontrol/js/ui/main.js:418
#: pretix/static/pretixcontrol/js/ui/main.js:438 #: pretix/static/pretixcontrol/js/ui/main.js:438
@@ -712,229 +714,226 @@ msgstr[0] "(una data més)"
msgstr[1] "({num} dates més)" msgstr[1] "({num} dates més)"
#: pretix/static/pretixpresale/js/ui/cart.js:47 #: pretix/static/pretixpresale/js/ui/cart.js:47
#, fuzzy
#| msgid "The items in your cart are no longer reserved for you."
msgid "" msgid ""
"The items in your cart are no longer reserved for you. You can still " "The items in your cart are no longer reserved for you. You can still "
"complete your order as long as theyre available." "complete your order as long as theyre available."
msgstr "El contingut de la cistella ja no el teniu reservat." msgstr ""
"Ja no tens reservat el contingut de la cistella. Encara pots completar la "
"comanda si els articles seleccionats continuen disponibles."
#: pretix/static/pretixpresale/js/ui/cart.js:49 #: pretix/static/pretixpresale/js/ui/cart.js:49
msgid "Cart expired" msgid "Cart expired"
msgstr "Cistella expirada" msgstr "Cistella caducada"
#: pretix/static/pretixpresale/js/ui/cart.js:58 #: pretix/static/pretixpresale/js/ui/cart.js:58
#: pretix/static/pretixpresale/js/ui/cart.js:84 #: pretix/static/pretixpresale/js/ui/cart.js:84
msgid "Your cart is about to expire." msgid "Your cart is about to expire."
msgstr "" msgstr "La teva cistella està a punt de caducar."
#: pretix/static/pretixpresale/js/ui/cart.js:62 #: pretix/static/pretixpresale/js/ui/cart.js:62
#, fuzzy
#| msgid "The items in your cart are no longer reserved for you."
msgid "The items in your cart are reserved for you for one minute." msgid "The items in your cart are reserved for you for one minute."
msgid_plural "The items in your cart are reserved for you for {num} minutes." msgid_plural "The items in your cart are reserved for you for {num} minutes."
msgstr[0] "El contingut de la cistella ja no el teniu reservat." msgstr[0] "El contingut de la teva cistella està reservat durant un minut."
msgstr[1] "El contingut de la cistella ja no el teniu reservat." msgstr[1] "El contingut de la teva cistella està reservat durant {num} minuts."
#: pretix/static/pretixpresale/js/ui/cart.js:83 #: pretix/static/pretixpresale/js/ui/cart.js:83
#, fuzzy
#| msgid "Cart expired"
msgid "Your cart has expired." msgid "Your cart has expired."
msgstr "Cistella expirada" msgstr "La teva cistella ha caducat."
#: pretix/static/pretixpresale/js/ui/cart.js:86 #: pretix/static/pretixpresale/js/ui/cart.js:86
#, fuzzy
#| msgid "The items in your cart are no longer reserved for you."
msgid "" msgid ""
"The items in your cart are no longer reserved for you. You can still " "The items in your cart are no longer reserved for you. You can still "
"complete your order as long as they're available." "complete your order as long as they're available."
msgstr "El contingut de la cistella ja no el teniu reservat." msgstr ""
"Ja no teniu reservat el contingut de la cistella. Encara podeu completar la "
"comanda si els articles continuen disponibles."
#: pretix/static/pretixpresale/js/ui/cart.js:87 #: pretix/static/pretixpresale/js/ui/cart.js:87
msgid "Do you want to renew the reservation period?" msgid "Do you want to renew the reservation period?"
msgstr "" msgstr "Vols ampliar el temps de reserva?"
#: pretix/static/pretixpresale/js/ui/cart.js:90 #: pretix/static/pretixpresale/js/ui/cart.js:90
msgid "Renew reservation" msgid "Renew reservation"
msgstr "" msgstr "Renovar reserva"
#: pretix/static/pretixpresale/js/ui/main.js:194 #: pretix/static/pretixpresale/js/ui/main.js:194
msgid "The organizer keeps %(currency)s %(amount)s" msgid "The organizer keeps %(currency)s %(amount)s"
msgstr "" msgstr "L'entitat organitzadora es queda %(currency)s %(amount)s"
#: pretix/static/pretixpresale/js/ui/main.js:202 #: pretix/static/pretixpresale/js/ui/main.js:202
msgid "You get %(currency)s %(amount)s back" msgid "You get %(currency)s %(amount)s back"
msgstr "" msgstr "Rebràs %(currency)s %(amount)s"
#: pretix/static/pretixpresale/js/ui/main.js:218 #: pretix/static/pretixpresale/js/ui/main.js:218
msgid "Please enter the amount the organizer can keep." msgid "Please enter the amount the organizer can keep."
msgstr "" msgstr "Introdueix limport que es queda lorganitzador."
#: pretix/static/pretixpresale/js/ui/main.js:570 #: pretix/static/pretixpresale/js/ui/main.js:570
msgid "Your local time:" msgid "Your local time:"
msgstr "" msgstr "La teva hora local:"
#: pretix/static/pretixpresale/js/walletdetection.js:39 #: pretix/static/pretixpresale/js/walletdetection.js:39
#, fuzzy
msgid "Google Pay" msgid "Google Pay"
msgstr "" msgstr "Google Pay"
#: pretix/static/pretixpresale/js/widget/widget.js:16 #: pretix/static/pretixpresale/js/widget/widget.js:16
msgctxt "widget" msgctxt "widget"
msgid "Quantity" msgid "Quantity"
msgstr "" msgstr "Quantitat"
#: pretix/static/pretixpresale/js/widget/widget.js:17 #: pretix/static/pretixpresale/js/widget/widget.js:17
msgctxt "widget" msgctxt "widget"
msgid "Decrease quantity" msgid "Decrease quantity"
msgstr "" msgstr "Disminueix quantitat"
#: pretix/static/pretixpresale/js/widget/widget.js:18 #: pretix/static/pretixpresale/js/widget/widget.js:18
msgctxt "widget" msgctxt "widget"
msgid "Increase quantity" msgid "Increase quantity"
msgstr "" msgstr "Augmenta quantitat"
#: pretix/static/pretixpresale/js/widget/widget.js:19 #: pretix/static/pretixpresale/js/widget/widget.js:19
msgctxt "widget" msgctxt "widget"
msgid "Filter events by" msgid "Filter events by"
msgstr "" msgstr "Filtra els esdeveniments per"
#: pretix/static/pretixpresale/js/widget/widget.js:20 #: pretix/static/pretixpresale/js/widget/widget.js:20
msgctxt "widget" msgctxt "widget"
msgid "Filter" msgid "Filter"
msgstr "" msgstr "Filtra"
#: pretix/static/pretixpresale/js/widget/widget.js:21 #: pretix/static/pretixpresale/js/widget/widget.js:21
msgctxt "widget" msgctxt "widget"
msgid "Price" msgid "Price"
msgstr "" msgstr "Preu"
#: pretix/static/pretixpresale/js/widget/widget.js:22 #: pretix/static/pretixpresale/js/widget/widget.js:22
#, javascript-format #, javascript-format
msgctxt "widget" msgctxt "widget"
msgid "Original price: %s" msgid "Original price: %s"
msgstr "" msgstr "Preu original: %s"
#: pretix/static/pretixpresale/js/widget/widget.js:23 #: pretix/static/pretixpresale/js/widget/widget.js:23
#, javascript-format #, javascript-format
msgctxt "widget" msgctxt "widget"
msgid "New price: %s" msgid "New price: %s"
msgstr "" msgstr "Nou preu: %s"
#: pretix/static/pretixpresale/js/widget/widget.js:24 #: pretix/static/pretixpresale/js/widget/widget.js:24
msgctxt "widget" msgctxt "widget"
msgid "Select" msgid "Select"
msgstr "" msgstr "Selecciona"
#: pretix/static/pretixpresale/js/widget/widget.js:25 #: pretix/static/pretixpresale/js/widget/widget.js:25
#, javascript-format #, javascript-format
msgctxt "widget" msgctxt "widget"
msgid "Select %s" msgid "Select %s"
msgstr "" msgstr "Selecciona %s"
#: pretix/static/pretixpresale/js/widget/widget.js:26 #: pretix/static/pretixpresale/js/widget/widget.js:26
#, javascript-format #, javascript-format
msgctxt "widget" msgctxt "widget"
msgid "Select variant %s" msgid "Select variant %s"
msgstr "" msgstr "Selecciona la variant %s"
#: pretix/static/pretixpresale/js/widget/widget.js:27 #: pretix/static/pretixpresale/js/widget/widget.js:27
msgctxt "widget" msgctxt "widget"
msgid "Sold out" msgid "Sold out"
msgstr "" msgstr "Esgotat"
#: pretix/static/pretixpresale/js/widget/widget.js:28 #: pretix/static/pretixpresale/js/widget/widget.js:28
msgctxt "widget" msgctxt "widget"
msgid "Buy" msgid "Buy"
msgstr "" msgstr "Compra"
#: pretix/static/pretixpresale/js/widget/widget.js:29 #: pretix/static/pretixpresale/js/widget/widget.js:29
msgctxt "widget" msgctxt "widget"
msgid "Register" msgid "Register"
msgstr "" msgstr "Registrar-vos"
#: pretix/static/pretixpresale/js/widget/widget.js:30 #: pretix/static/pretixpresale/js/widget/widget.js:30
msgctxt "widget" msgctxt "widget"
msgid "Reserved" msgid "Reserved"
msgstr "" msgstr "Reservat"
#: pretix/static/pretixpresale/js/widget/widget.js:31 #: pretix/static/pretixpresale/js/widget/widget.js:31
msgctxt "widget" msgctxt "widget"
msgid "FREE" msgid "FREE"
msgstr "" msgstr "GRATUÏT"
#: pretix/static/pretixpresale/js/widget/widget.js:32 #: pretix/static/pretixpresale/js/widget/widget.js:32
msgctxt "widget" msgctxt "widget"
msgid "from %(currency)s %(price)s" msgid "from %(currency)s %(price)s"
msgstr "" msgstr "des de %(price)s %(currency)s"
#: pretix/static/pretixpresale/js/widget/widget.js:33 #: pretix/static/pretixpresale/js/widget/widget.js:33
#, javascript-format #, javascript-format
msgctxt "widget" msgctxt "widget"
msgid "Image of %s" msgid "Image of %s"
msgstr "" msgstr "Imatge de %s"
#: pretix/static/pretixpresale/js/widget/widget.js:34 #: pretix/static/pretixpresale/js/widget/widget.js:34
msgctxt "widget" msgctxt "widget"
msgid "incl. %(rate)s% %(taxname)s" msgid "incl. %(rate)s% %(taxname)s"
msgstr "" msgstr "Inclòs %(rate)s% %(taxname)s"
#: pretix/static/pretixpresale/js/widget/widget.js:35 #: pretix/static/pretixpresale/js/widget/widget.js:35
msgctxt "widget" msgctxt "widget"
msgid "plus %(rate)s% %(taxname)s" msgid "plus %(rate)s% %(taxname)s"
msgstr "" msgstr "més %(rate)s% %(taxname)s"
#: pretix/static/pretixpresale/js/widget/widget.js:36 #: pretix/static/pretixpresale/js/widget/widget.js:36
msgctxt "widget" msgctxt "widget"
msgid "incl. taxes" msgid "incl. taxes"
msgstr "" msgstr "incl. impostos"
#: pretix/static/pretixpresale/js/widget/widget.js:37 #: pretix/static/pretixpresale/js/widget/widget.js:37
msgctxt "widget" msgctxt "widget"
msgid "plus taxes" msgid "plus taxes"
msgstr "" msgstr "més impostos"
#: pretix/static/pretixpresale/js/widget/widget.js:38 #: pretix/static/pretixpresale/js/widget/widget.js:38
#, javascript-format #, javascript-format
msgctxt "widget" msgctxt "widget"
msgid "currently available: %s" msgid "currently available: %s"
msgstr "" msgstr "disponibles: %s"
#: pretix/static/pretixpresale/js/widget/widget.js:39 #: pretix/static/pretixpresale/js/widget/widget.js:39
msgctxt "widget" msgctxt "widget"
msgid "Only available with a voucher" msgid "Only available with a voucher"
msgstr "" msgstr "Només disponible amb un cupó"
#: pretix/static/pretixpresale/js/widget/widget.js:40 #: pretix/static/pretixpresale/js/widget/widget.js:40
#: pretix/static/pretixpresale/js/widget/widget.js:43 #: pretix/static/pretixpresale/js/widget/widget.js:43
msgctxt "widget" msgctxt "widget"
msgid "Not yet available" msgid "Not yet available"
msgstr "" msgstr "Encara no disponible"
#: pretix/static/pretixpresale/js/widget/widget.js:41 #: pretix/static/pretixpresale/js/widget/widget.js:41
msgctxt "widget" msgctxt "widget"
msgid "Not available anymore" msgid "Not available anymore"
msgstr "" msgstr "Ja no està disponible"
#: pretix/static/pretixpresale/js/widget/widget.js:42 #: pretix/static/pretixpresale/js/widget/widget.js:42
msgctxt "widget" msgctxt "widget"
msgid "Currently not available" msgid "Currently not available"
msgstr "" msgstr "No disponible"
#: pretix/static/pretixpresale/js/widget/widget.js:44 #: pretix/static/pretixpresale/js/widget/widget.js:44
#, javascript-format #, javascript-format
msgctxt "widget" msgctxt "widget"
msgid "minimum amount to order: %s" msgid "minimum amount to order: %s"
msgstr "" msgstr "Comanda mínima: %s"
#: pretix/static/pretixpresale/js/widget/widget.js:45 #: pretix/static/pretixpresale/js/widget/widget.js:45
msgctxt "widget" msgctxt "widget"
msgid "Close ticket shop" msgid "Close ticket shop"
msgstr "" msgstr "Tanca la botiga d'entrades"
#: pretix/static/pretixpresale/js/widget/widget.js:46 #: pretix/static/pretixpresale/js/widget/widget.js:46
msgctxt "widget" msgctxt "widget"
msgid "The ticket shop could not be loaded." msgid "The ticket shop could not be loaded."
msgstr "" msgstr "No s'ha pogut carregar la botiga d'entrades."
#: pretix/static/pretixpresale/js/widget/widget.js:47 #: pretix/static/pretixpresale/js/widget/widget.js:47
msgctxt "widget" msgctxt "widget"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -362,6 +362,9 @@ Reverse-Proxy
Revisionssicherheit Revisionssicherheit
Revolut Revolut
Revolut-App Revolut-App
Rundungsabweichungen
Rundungskorrektur
Rundungsverhalten
rückabgewickelt rückabgewickelt
Sa Sa
Sammlungsstücken Sammlungsstücken
File diff suppressed because it is too large Load Diff
@@ -362,6 +362,9 @@ Reverse-Proxy
Revisionssicherheit Revisionssicherheit
Revolut Revolut
Revolut-App Revolut-App
Rundungsabweichungen
Rundungskorrektur
Rundungsverhalten
rückabgewickelt rückabgewickelt
Sa Sa
Sammlungsstücken Sammlungsstücken
+1160 -1062
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-29 07:55+0000\n" "POT-Creation-Date: 2025-10-30 10:55+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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