mirror of
https://github.com/pretix/pretix.git
synced 2026-06-17 02:21:05 +00:00
Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e076f4bbd9 | |||
| 1a2ee155bd | |||
| 9743d7ae52 | |||
| 07f38819a6 | |||
| b4dd2145ff | |||
| 1c26036976 | |||
| a53fc2d256 | |||
| 8d24696ce3 | |||
| a14c545883 | |||
| 10829aa2a5 | |||
| f645b5a2d9 | |||
| d3fde85c39 | |||
| 40bd66cb86 | |||
| bdd94b1f8a | |||
| 1c907f6a6f | |||
| 39e3ed9c25 | |||
| 4b5711253e | |||
| bd554c7c29 | |||
| 2261951b15 | |||
| 0f82e1cae6 | |||
| b0760157ce | |||
| de2dec9089 | |||
| 446c8e622b | |||
| 703be2ebb8 | |||
| a56fbc896c | |||
| 7b6f5df985 | |||
| d2087907d5 | |||
| cbc2e611a2 | |||
| 02126a48fe | |||
| be9af94131 | |||
| dbe1944996 | |||
| 6181bdc2e9 | |||
| fe40d1c491 | |||
| 9f263fbe4f | |||
| fdd34f387a | |||
| bfab523d83 | |||
| 8f69cb166d | |||
| 2fc7c23960 | |||
| b0911c9e42 | |||
| a5aa1030e5 | |||
| 681e682e73 | |||
| db7518735a | |||
| 9c80f3038a | |||
| 4dc5bbae06 | |||
| e997ca4242 | |||
| 278b4301e5 | |||
| b648f9c46c | |||
| 9ce16b60d2 | |||
| f4a7604632 | |||
| 7cebb3e93f | |||
| c82726e13d | |||
| 2fcfc336d0 | |||
| 39ff84b2e2 | |||
| 44804f05f3 | |||
| 5e828ab8af | |||
| 313f4f326b | |||
| ed43bf327e | |||
| 30aabc6253 | |||
| 5eade62121 | |||
| 2669afa1f8 | |||
| d42c6f9b72 | |||
| 34f064ca33 | |||
| ad8d0a270c | |||
| 363fcc3b56 | |||
| 9521ec2c52 | |||
| 688d341baf | |||
| cdd4001378 | |||
| d8d56ff020 | |||
| 44b3647689 | |||
| 818bb76e89 | |||
| 8c01cad06b | |||
| 86ca7c4440 | |||
| d7b6856322 | |||
| e2d9cbb41d | |||
| 57bc7563da | |||
| 7741e9f936 | |||
| 2f08bb465a | |||
| 4fb048e3a9 | |||
| 82af3012bd | |||
| 11425f21e6 | |||
| 55f35a998b | |||
| 53cfce2ce7 | |||
| 68ce335034 | |||
| 6ce5c1a26a | |||
| ae4540acd7 | |||
| a814d31c9b | |||
| ef9863518b | |||
| eb740204d4 | |||
| 5583298322 | |||
| 74b06435a0 | |||
| a26b0c5512 | |||
| 095e07b3f1 | |||
| b2eb1b6231 | |||
| 9d838f1d9c | |||
| cbf6bd29b0 | |||
| 0e84df9af2 | |||
| 7feacc8a1a | |||
| 5ada22dd15 | |||
| 6d56011695 | |||
| da167eacd5 | |||
| 5df0c55daa | |||
| b01e798b48 | |||
| 0256ee76db | |||
| e99eecb8be | |||
| d1ae579a6f | |||
| 90d3f50eba | |||
| c1b6d660a4 | |||
| 0b88b63597 | |||
| 0cc6439748 | |||
| ad53c48d0f | |||
| 59a5c11ef6 | |||
| 1cb2d443f9 | |||
| a0dbf6c5db | |||
| fd9d03786b | |||
| 7041d40972 | |||
| 0b46982e6d | |||
| 29906c6288 | |||
| 3380bd3e82 | |||
| 6ae8e7cbb6 | |||
| 23c2d9266e | |||
| ba155faaa3 | |||
| fd177fa89f | |||
| 0b051c1400 | |||
| af8d0f0b65 | |||
| 1b7bba195c | |||
| f056f77dc0 | |||
| ee4e7f618f | |||
| cd450f1780 | |||
| fc876978b2 | |||
| d8efaa47f7 | |||
| f0c3514588 | |||
| e1ad4d9dba | |||
| 3ab587883e | |||
| b02e1a1515 | |||
| 41780add40 | |||
| b07a61e4f1 | |||
| dead2a9bed | |||
| 94389c3913 | |||
| 3e972eddbf | |||
| cdeb1e86bd | |||
| 9a69b76880 | |||
| 7d5df2b69e | |||
| d203eee5ab |
@@ -421,3 +421,94 @@ Annulment of a check-in
|
||||
:statuscode 401: Authentication failure
|
||||
: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.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -19,6 +19,7 @@ at :ref:`plugin-docs`.
|
||||
item_bundles
|
||||
item_add-ons
|
||||
item_meta_properties
|
||||
item_program_times
|
||||
questions
|
||||
question_options
|
||||
quotas
|
||||
|
||||
@@ -22,6 +22,7 @@ invoice_from_name string Sender address:
|
||||
invoice_from string Sender address: Address lines
|
||||
invoice_from_zipcode string Sender address: ZIP code
|
||||
invoice_from_city string Sender address: City
|
||||
invoice_from_state string Sender address: State (only used in some countries)
|
||||
invoice_from_country string Sender address: Country code
|
||||
invoice_from_tax_id string Sender address: Local Tax ID
|
||||
invoice_from_vat_id string Sender address: EU VAT ID
|
||||
@@ -233,6 +234,7 @@ List of all invoices
|
||||
"invoice_from": "Demo street 12",
|
||||
"invoice_from_zipcode":"",
|
||||
"invoice_from_city":"Demo town",
|
||||
"invoice_from_state":"CA",
|
||||
"invoice_from_country":"US",
|
||||
"invoice_from_tax_id":"",
|
||||
"invoice_from_vat_id":"",
|
||||
@@ -381,6 +383,7 @@ Fetching individual invoices
|
||||
"invoice_from": "Demo street 12",
|
||||
"invoice_from_zipcode":"",
|
||||
"invoice_from_city":"Demo town",
|
||||
"invoice_from_state":"CA",
|
||||
"invoice_from_country":"US",
|
||||
"invoice_from_tax_id":"",
|
||||
"invoice_from_vat_id":"",
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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.
|
||||
Note that ``program_times`` are not available for items inside event series.
|
||||
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.
|
||||
+36
-10
@@ -139,6 +139,10 @@ has_variations boolean Shows whether
|
||||
variations list of objects A list with one object for each variation of this item.
|
||||
Can be empty. Only writable during creation,
|
||||
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.
|
||||
Not available for items in event series.
|
||||
├ id integer Internal ID 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``
|
||||
@@ -225,6 +229,10 @@ meta_data object Values set fo
|
||||
|
||||
The ``hidden_if_item_available_mode`` attributes has been added.
|
||||
|
||||
.. versionchanged:: 2025.9
|
||||
|
||||
The ``program_times`` attribute has been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
|
||||
@@ -232,9 +240,11 @@ 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
|
||||
one variation.
|
||||
|
||||
Also note that ``variations``, ``bundles``, and ``addons`` 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``
|
||||
with nested ``variations``, ``bundles`` and/or ``addons``.
|
||||
Also note that ``variations``, ``bundles``, ``addons`` and ``program_times`` are only supported on ``POST``. To update/delete variations,
|
||||
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``, ``addons`` and/or ``program_times``.
|
||||
|
||||
``program_times`` is not available to items in event series.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -373,7 +383,8 @@ Endpoints
|
||||
}
|
||||
],
|
||||
"addons": [],
|
||||
"bundles": []
|
||||
"bundles": [],
|
||||
"program_times": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -525,7 +536,8 @@ Endpoints
|
||||
}
|
||||
],
|
||||
"addons": [],
|
||||
"bundles": []
|
||||
"bundles": [],
|
||||
"program_times": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -653,7 +665,13 @@ Endpoints
|
||||
}
|
||||
],
|
||||
"addons": [],
|
||||
"bundles": []
|
||||
"bundles": [],
|
||||
"program_times": [
|
||||
{
|
||||
"start": "2025-08-14T22:00:00Z",
|
||||
"end": "2025-08-15T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -773,7 +791,13 @@ Endpoints
|
||||
}
|
||||
],
|
||||
"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
|
||||
@@ -789,8 +813,9 @@ Endpoints
|
||||
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 ``has_variations``, ``variations`` and the ``addon`` field. If
|
||||
you need to update/delete variations or add-ons please use the nested dedicated endpoints.
|
||||
You can change all fields of the resource except the ``has_variations``, ``variations``, ``addon`` and the
|
||||
``program_times`` field. If you need to update/delete variations, add-ons or program times, please use the nested
|
||||
dedicated endpoints.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -924,7 +949,8 @@ Endpoints
|
||||
}
|
||||
],
|
||||
"addons": [],
|
||||
"bundles": []
|
||||
"bundles": [],
|
||||
"program_times": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
|
||||
@@ -41,6 +41,7 @@ expires datetime The order will
|
||||
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
|
||||
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for 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
|
||||
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.
|
||||
@@ -151,6 +152,10 @@ plugin_data object Additional data
|
||||
|
||||
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
|
||||
@@ -358,6 +363,7 @@ List of all orders
|
||||
"payment_provider": "banktransfer",
|
||||
"fees": [],
|
||||
"total": "23.00",
|
||||
"tax_rounding_mode": "line",
|
||||
"comment": "",
|
||||
"custom_followup_at": null,
|
||||
"checkin_attention": false,
|
||||
@@ -602,6 +608,7 @@ Fetching individual orders
|
||||
"payment_provider": "banktransfer",
|
||||
"fees": [],
|
||||
"total": "23.00",
|
||||
"tax_rounding_mode": "line",
|
||||
"comment": "",
|
||||
"api_meta": {},
|
||||
"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*
|
||||
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.
|
||||
* ``tax_rounding_mode`` (optional)
|
||||
* ``comment`` (optional)
|
||||
* ``custom_followup_at`` (optional)
|
||||
* ``checkin_attention`` (optional)
|
||||
|
||||
@@ -178,3 +178,124 @@ Flowchart
|
||||
---------
|
||||
|
||||
.. 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 |
@@ -23,6 +23,7 @@ partition "For every cart position" {
|
||||
--> "Store as line_price (gross), tax_rate"
|
||||
}
|
||||
--> "Apply discount engine"
|
||||
--> "Apply tax rounding"
|
||||
--> "Store as price (gross)"
|
||||
|
||||
@enduml
|
||||
|
||||
+5
-4
@@ -33,9 +33,10 @@ dependencies = [
|
||||
"celery==5.5.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=44.0.0",
|
||||
"css-inline==0.17.*",
|
||||
"css-inline==0.18.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"Django[argon2]==4.2.*,>=4.2.24",
|
||||
"dnspython==2.*",
|
||||
"Django[argon2]==4.2.*,>=4.2.26",
|
||||
"django-bootstrap3==25.2",
|
||||
"django-compressor==4.5.1",
|
||||
"django-countries==7.6.*",
|
||||
@@ -81,7 +82,7 @@ dependencies = [
|
||||
"pycountry",
|
||||
"pycparser==2.23",
|
||||
"pycryptodome==3.23.*",
|
||||
"pypdf==6.1.*",
|
||||
"pypdf==6.4.*",
|
||||
"python-bidi==0.6.*", # Support for Arabic in reportlab
|
||||
"python-dateutil==2.9.*",
|
||||
"pytz",
|
||||
@@ -91,7 +92,7 @@ dependencies = [
|
||||
"redis==6.4.*",
|
||||
"reportlab==4.4.*",
|
||||
"requests==2.32.*",
|
||||
"sentry-sdk==2.42.*",
|
||||
"sentry-sdk==2.46.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "2025.9.0"
|
||||
__version__ = "2025.11.0.dev0"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.24 on 2025-11-14 16:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixapi", "0013_alter_webhookcallretry_retry_not_before"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="webhook",
|
||||
name="target_url",
|
||||
field=models.URLField(max_length=1024),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="webhookcall",
|
||||
name="target_url",
|
||||
field=models.URLField(max_length=1024),
|
||||
),
|
||||
]
|
||||
@@ -114,7 +114,7 @@ class OAuthRefreshToken(AbstractRefreshToken):
|
||||
class WebHook(models.Model):
|
||||
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
|
||||
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
|
||||
target_url = models.URLField(verbose_name=_("Target URL"), max_length=255)
|
||||
target_url = models.URLField(verbose_name=_("Target URL"), max_length=1024)
|
||||
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
|
||||
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
|
||||
comment = models.CharField(verbose_name=_("Comment"), max_length=255, null=True, blank=True)
|
||||
@@ -140,7 +140,7 @@ class WebHookEventListener(models.Model):
|
||||
class WebHookCall(models.Model):
|
||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
|
||||
datetime = models.DateTimeField(auto_now_add=True)
|
||||
target_url = models.URLField(max_length=255)
|
||||
target_url = models.URLField(max_length=1024)
|
||||
action_type = models.CharField(max_length=255)
|
||||
is_retry = models.BooleanField(default=False)
|
||||
execution_time = models.FloatField(null=True)
|
||||
|
||||
@@ -820,6 +820,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'invoice_address_from',
|
||||
'invoice_address_from_zipcode',
|
||||
'invoice_address_from_city',
|
||||
'invoice_address_from_state',
|
||||
'invoice_address_from_country',
|
||||
'invoice_address_from_tax_id',
|
||||
'invoice_address_from_vat_id',
|
||||
@@ -829,6 +830,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'invoice_eu_currencies',
|
||||
'invoice_logo_image',
|
||||
'invoice_renderer_highlight_order_code',
|
||||
'tax_rounding',
|
||||
'cancel_allow_user',
|
||||
'cancel_allow_user_until',
|
||||
'cancel_allow_user_unpaid_keep',
|
||||
@@ -951,6 +953,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'invoice_address_from',
|
||||
'invoice_address_from_zipcode',
|
||||
'invoice_address_from_city',
|
||||
'invoice_address_from_state',
|
||||
'invoice_address_from_country',
|
||||
'invoice_address_from_tax_id',
|
||||
'invoice_address_from_vat_id',
|
||||
|
||||
@@ -47,8 +47,9 @@ from pretix.api.serializers.event import MetaDataField
|
||||
from pretix.api.serializers.fields import UploadedFileField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
|
||||
ItemVariationMetaValue, Question, QuestionOption, Quota, SalesChannel,
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemProgramTime,
|
||||
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
||||
SalesChannel,
|
||||
)
|
||||
|
||||
|
||||
@@ -187,6 +188,12 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
|
||||
'position', 'price_included', 'multi_allowed')
|
||||
|
||||
|
||||
class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemProgramTime
|
||||
fields = ('start', 'end')
|
||||
|
||||
|
||||
class ItemBundleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemBundle
|
||||
@@ -212,6 +219,37 @@ class ItemBundleSerializer(serializers.ModelSerializer):
|
||||
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."))
|
||||
|
||||
event = self.context['event']
|
||||
if event.has_subevents:
|
||||
raise ValidationError({
|
||||
_("You cannot use program times on an event series.")
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class ItemAddOnSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemAddOn
|
||||
@@ -250,6 +288,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
addons = InlineItemAddOnSerializer(many=True, required=False)
|
||||
bundles = InlineItemBundleSerializer(many=True, required=False)
|
||||
variations = InlineItemVariationSerializer(many=True, required=False)
|
||||
program_times = InlineItemProgramTimeSerializer(many=True, required=False)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
||||
@@ -271,7 +310,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||
'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',
|
||||
'issue_giftcard', 'meta_data',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
|
||||
@@ -294,9 +333,9 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data):
|
||||
raise ValidationError(_('Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use the '
|
||||
'dedicated nested endpoint.'))
|
||||
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, program times or variations via PATCH/PUT is not '
|
||||
'supported. Please use the dedicated nested endpoint.'))
|
||||
|
||||
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'))
|
||||
@@ -347,6 +386,13 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
ItemAddOn.clean_max_min_count(addon_data.get('max_count', 0), addon_data.get('min_count', 0))
|
||||
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
|
||||
def item_meta_properties(self):
|
||||
return {
|
||||
@@ -364,6 +410,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
variations_data = validated_data.pop('variations') if 'variations' 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 {}
|
||||
program_times_data = validated_data.pop('program_times') if 'program_times' in validated_data else {}
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
picture = validated_data.pop('picture', None)
|
||||
require_membership_types = validated_data.pop('require_membership_types', [])
|
||||
@@ -398,6 +445,8 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
ItemAddOn.objects.create(base_item=item, **addon_data)
|
||||
for bundle_data in bundles_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
|
||||
if meta_data is not None:
|
||||
|
||||
@@ -52,9 +52,10 @@ from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.invoicing.transmission import get_transmission_types
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
|
||||
ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
|
||||
ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher,
|
||||
CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
|
||||
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
|
||||
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
|
||||
Voucher,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
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.locking import LOCK_TRUST_WINDOW, lock_objects
|
||||
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.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.helpers.countries import CachedCountries
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
@@ -325,7 +329,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class CheckinSerializer(I18nAwareModelSerializer):
|
||||
class InlineCheckinSerializer(I18nAwareModelSerializer):
|
||||
device_id = serializers.SlugRelatedField(
|
||||
source='device',
|
||||
slug_field='device_id',
|
||||
@@ -337,6 +341,21 @@ class CheckinSerializer(I18nAwareModelSerializer):
|
||||
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):
|
||||
device_id = serializers.SlugRelatedField(
|
||||
source='device',
|
||||
@@ -560,7 +579,7 @@ class OrderPositionPluginDataField(serializers.Field):
|
||||
|
||||
|
||||
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)
|
||||
answers = AnswerSerializer(many=True)
|
||||
downloads = PositionDownloadsField(source='*', read_only=True)
|
||||
@@ -833,14 +852,15 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
list_serializer_class = OrderListSerializer
|
||||
fields = (
|
||||
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
|
||||
'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
|
||||
'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', 'plugin_data',
|
||||
'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'comment', 'custom_followup_at', 'invoice_address',
|
||||
'positions', 'downloads', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds',
|
||||
'require_approval', 'sales_channel', 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date',
|
||||
'plugin_data',
|
||||
)
|
||||
read_only_fields = (
|
||||
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer',
|
||||
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date'
|
||||
'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'positions', 'downloads', 'customer',
|
||||
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -1159,6 +1179,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
queryset=SalesChannel.objects.none(),
|
||||
required=False,
|
||||
)
|
||||
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
|
||||
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
|
||||
|
||||
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',
|
||||
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
|
||||
'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):
|
||||
if pp is None:
|
||||
@@ -1701,7 +1722,31 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
else:
|
||||
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:
|
||||
order.fees = fees
|
||||
order.positions = pos_map.values()
|
||||
@@ -1786,7 +1831,7 @@ class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Invoice
|
||||
fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
|
||||
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
|
||||
'invoice_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
|
||||
'invoice_to', 'invoice_to_is_business', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street',
|
||||
'invoice_to_zipcode', 'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id',
|
||||
'invoice_to_beneficiary', 'invoice_to_transmission_info', 'custom_field', 'date', 'refers', 'locale',
|
||||
|
||||
@@ -92,6 +92,7 @@ event_router.register(r'taxrules', event.TaxRuleViewSet)
|
||||
event_router.register(r'seats', event.SeatViewSet)
|
||||
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
||||
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'scheduled_exports', exporters.ScheduledEventExportViewSet)
|
||||
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'addons', item.ItemAddOnViewSet)
|
||||
item_router.register(r'bundles', item.ItemBundleViewSet)
|
||||
item_router.register(r'program_times', item.ItemProgramTimeViewSet)
|
||||
|
||||
order_router = routers.DefaultRouter()
|
||||
order_router.register(r'payments', order.PaymentViewSet)
|
||||
|
||||
@@ -56,7 +56,8 @@ from pretix.api.serializers.checkin import (
|
||||
)
|
||||
from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
CheckinListOrderPositionSerializer, FailedCheckinSerializer,
|
||||
CheckinListOrderPositionSerializer, CheckinSerializer,
|
||||
FailedCheckinSerializer,
|
||||
)
|
||||
from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
@@ -96,6 +97,16 @@ with scopes_disabled():
|
||||
)
|
||||
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):
|
||||
serializer_class = CheckinListSerializer
|
||||
@@ -1080,3 +1091,25 @@ class CheckinRPCAnnulView(views.APIView):
|
||||
checkin_annulled.send(ci.position.order.event, checkin=ci)
|
||||
|
||||
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
|
||||
|
||||
@@ -40,19 +40,19 @@ from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.pagination import TotalOrderingFilter
|
||||
from pretix.api.serializers.item import (
|
||||
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
||||
ItemSerializer, ItemVariationSerializer, QuestionOptionSerializer,
|
||||
QuestionSerializer, QuotaSerializer,
|
||||
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
|
||||
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation,
|
||||
Question, QuestionOption, Quota,
|
||||
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
|
||||
ItemVariation, Question, QuestionOption, Quota,
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
@@ -279,6 +279,59 @@ 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):
|
||||
if self.request.event.has_subevents:
|
||||
raise ValidationError('You cannot use program times on an event series.')
|
||||
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):
|
||||
serializer_class = ItemAddOnSerializer
|
||||
queryset = ItemAddOn.objects.none()
|
||||
|
||||
@@ -344,6 +344,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['auth'] = self.request.auth
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.celery import get_task_priority
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_ALL_EVENTS = None
|
||||
@@ -474,7 +475,10 @@ def notify_webhooks(logentry_ids: list):
|
||||
)
|
||||
|
||||
for wh in webhooks:
|
||||
send_webhook.apply_async(args=(logentry.id, notification_type.action_type, wh.pk))
|
||||
send_webhook.apply_async(
|
||||
args=(logentry.id, notification_type.action_type, wh.pk),
|
||||
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||
)
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=60, acks_late=True, autoretry_for=(DatabaseError,),)
|
||||
|
||||
@@ -112,23 +112,6 @@ def oidc_validate_and_complete_config(config):
|
||||
scope="openid",
|
||||
))
|
||||
|
||||
for scope in config["scope"].split(" "):
|
||||
if scope not in provider_config.get("scopes_supported", []):
|
||||
raise ValidationError(_('You are requesting scope "{scope}" but provider only supports these: {scopes}.').format(
|
||||
scope=scope,
|
||||
scopes=", ".join(provider_config.get("scopes_supported", []))
|
||||
))
|
||||
|
||||
if "claims_supported" in provider_config:
|
||||
claims_supported = provider_config.get("claims_supported", [])
|
||||
for k, v in config.items():
|
||||
if k.endswith('_field') and v:
|
||||
if v not in claims_supported: # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
|
||||
raise ValidationError(_('You are requesting field "{field}" but provider only supports these: {fields}.').format(
|
||||
field=v,
|
||||
fields=", ".join(provider_config.get("claims_supported", []))
|
||||
))
|
||||
|
||||
if "token_endpoint_auth_methods_supported" in provider_config:
|
||||
token_endpoint_auth_methods_supported = provider_config.get("token_endpoint_auth_methods_supported",
|
||||
["client_secret_basic"])
|
||||
|
||||
@@ -24,6 +24,7 @@ from itertools import groupby
|
||||
from smtplib import SMTPResponseException
|
||||
from typing import TypeVar
|
||||
|
||||
import bleach
|
||||
import css_inline
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
@@ -34,7 +35,10 @@ from django.utils.translation import get_language, gettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.signals import register_html_mail_renderers
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
from pretix.base.templatetags.rich_text import (
|
||||
DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback,
|
||||
markdown_compile_email, truelink_callback,
|
||||
)
|
||||
from pretix.helpers.format import SafeFormatter, format_map
|
||||
|
||||
from pretix.base.services.placeholders import ( # noqa
|
||||
@@ -133,13 +137,24 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
def template_name(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def compile_markdown(self, plaintext):
|
||||
return markdown_compile_email(plaintext)
|
||||
def compile_markdown(self, plaintext, context=None):
|
||||
return markdown_compile_email(plaintext, context=context)
|
||||
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
|
||||
body_md = self.compile_markdown(plain_body)
|
||||
body_md = self.compile_markdown(plain_body, context)
|
||||
if context:
|
||||
body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
|
||||
linker = bleach.Linker(
|
||||
url_re=URL_RE,
|
||||
email_re=EMAIL_RE,
|
||||
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||
parse_email=True
|
||||
)
|
||||
body_md = format_map(
|
||||
body_md,
|
||||
context=context,
|
||||
mode=SafeFormatter.MODE_RICH_TO_HTML,
|
||||
linkifier=linker
|
||||
)
|
||||
htmlctx = {
|
||||
'site': settings.PRETIX_INSTANCE_NAME,
|
||||
'site_url': settings.SITE_URL,
|
||||
|
||||
@@ -209,6 +209,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
_('Invoice sender:') + ' ' + _('Address'),
|
||||
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||
_('Invoice sender:') + ' ' + _('City'),
|
||||
_('Invoice sender:') + ' ' + pgettext('address', 'State'),
|
||||
_('Invoice sender:') + ' ' + _('Country'),
|
||||
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||
@@ -291,6 +292,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
i.invoice_from,
|
||||
i.invoice_from_zipcode,
|
||||
i.invoice_from_city,
|
||||
i.invoice_from_state,
|
||||
i.invoice_from_country,
|
||||
i.invoice_from_tax_id,
|
||||
i.invoice_from_vat_id,
|
||||
|
||||
@@ -149,7 +149,7 @@ class ItemDataExporter(ListExporter):
|
||||
row += [
|
||||
_("Yes") if i.active and v.active else "",
|
||||
", ".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 "",
|
||||
str(i.tax_rule) if i.tax_rule else "",
|
||||
_("Yes") if i.admission else "",
|
||||
|
||||
@@ -610,7 +610,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('Attendee name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Attendee email'),
|
||||
_('Company'),
|
||||
_('Attendee company'),
|
||||
_('Address'),
|
||||
_('ZIP code'),
|
||||
_('City'),
|
||||
@@ -650,7 +650,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
options[q.pk].append(o)
|
||||
headers.append(str(q.question))
|
||||
headers += [
|
||||
_('Company'),
|
||||
_('Invoice address company'),
|
||||
_('Invoice address name'),
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
|
||||
@@ -214,21 +214,38 @@ class PasswordRecoverForm(forms.Form):
|
||||
error_messages = {
|
||||
'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(
|
||||
label=_('Password'),
|
||||
widget=forms.PasswordInput,
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'autocomplete': 'new-password',
|
||||
}),
|
||||
max_length=4096,
|
||||
required=True
|
||||
)
|
||||
password_repeat = forms.CharField(
|
||||
label=_('Repeat password'),
|
||||
widget=forms.PasswordInput,
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'autocomplete': 'new-password',
|
||||
}),
|
||||
max_length=4096,
|
||||
)
|
||||
|
||||
def __init__(self, user_id=None, *args, **kwargs):
|
||||
self.user_id = user_id
|
||||
super().__init__(*args, **kwargs)
|
||||
initial = kwargs.pop('initial', {})
|
||||
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):
|
||||
password1 = self.cleaned_data.get('password', '')
|
||||
@@ -243,11 +260,7 @@ class PasswordRecoverForm(forms.Form):
|
||||
|
||||
def clean_password(self):
|
||||
password1 = self.cleaned_data.get('password', '')
|
||||
try:
|
||||
user = User.objects.get(id=self.user_id)
|
||||
except User.DoesNotExist:
|
||||
user = None
|
||||
if validate_password(password1, user=user) is not None:
|
||||
if validate_password(password1, user=self.user) is not None:
|
||||
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
|
||||
return password1
|
||||
|
||||
@@ -307,3 +320,10 @@ class ReauthForm(forms.Form):
|
||||
self.error_messages['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'}),
|
||||
)
|
||||
|
||||
@@ -39,37 +39,16 @@ from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
)
|
||||
from django.db.models import Q
|
||||
from django.urls.base import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from pytz import common_timezones
|
||||
|
||||
from pretix.base.models import User
|
||||
from pretix.control.forms import SingleLanguageWidget
|
||||
from pretix.helpers.format import format_map
|
||||
|
||||
|
||||
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(
|
||||
choices=((a, a) for a in common_timezones),
|
||||
label=_("Default timezone"),
|
||||
@@ -93,16 +72,63 @@ class UserSettingsForm(forms.ModelForm):
|
||||
self.user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['email'].required = True
|
||||
if self.user.auth_backend != 'native':
|
||||
del self.fields['old_pw']
|
||||
del self.fields['new_pw']
|
||||
del self.fields['new_pw_repeat']
|
||||
self.fields['email'].disabled = True
|
||||
self.fields['email'].disabled = True
|
||||
self.fields['email'].help_text = format_map('<a href="{link}"><span class="fa fa-edit"></span> {text}</a>', {
|
||||
'text': _("Change email address"),
|
||||
'link': reverse('control:user.settings.email.change')
|
||||
})
|
||||
|
||||
|
||||
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_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=True,
|
||||
label=_("Your current password"),
|
||||
widget=forms.PasswordInput(
|
||||
attrs={'autocomplete': 'current-password'},
|
||||
))
|
||||
new_pw = forms.CharField(max_length=255,
|
||||
required=True,
|
||||
label=_("New password"),
|
||||
widget=forms.PasswordInput(
|
||||
attrs={'autocomplete': 'new-password'},
|
||||
))
|
||||
new_pw_repeat = forms.CharField(max_length=255,
|
||||
required=True,
|
||||
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):
|
||||
old_pw = self.cleaned_data.get('old_pw')
|
||||
|
||||
if old_pw and settings.HAS_REDIS:
|
||||
if settings.HAS_REDIS:
|
||||
from django_redis import get_redis_connection
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
|
||||
@@ -113,7 +139,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
code='rate_limit',
|
||||
)
|
||||
|
||||
if old_pw and not check_password(old_pw, self.user.password):
|
||||
if not check_password(old_pw, self.user.password):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_current_wrong'],
|
||||
code='pw_current_wrong',
|
||||
@@ -121,59 +147,47 @@ class UserSettingsForm(forms.ModelForm):
|
||||
|
||||
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):
|
||||
password1 = self.cleaned_data.get('new_pw', '')
|
||||
if password1 and validate_password(password1, user=self.user) is not None:
|
||||
if validate_password(password1, user=self.user) is not None:
|
||||
raise forms.ValidationError(
|
||||
_(password_validators_help_texts()),
|
||||
code='pw_invalid'
|
||||
)
|
||||
if self.user.check_password(password1):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_equal'],
|
||||
code='pw_equal',
|
||||
)
|
||||
return password1
|
||||
|
||||
def clean_new_pw_repeat(self):
|
||||
password1 = self.cleaned_data.get('new_pw')
|
||||
password2 = self.cleaned_data.get('new_pw_repeat')
|
||||
if password1 and password1 != password2:
|
||||
if password1 != password2:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['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(
|
||||
self.error_messages['pw_current'],
|
||||
code='pw_current'
|
||||
self.error_messages['duplicate_identifier'],
|
||||
code='duplicate_identifier',
|
||||
)
|
||||
|
||||
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)')),
|
||||
))
|
||||
return email
|
||||
|
||||
@@ -23,6 +23,7 @@ import datetime
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import textwrap
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
@@ -752,11 +753,59 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
return dt.astimezone(tz).date()
|
||||
|
||||
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(
|
||||
all_lines,
|
||||
key=_group_key,
|
||||
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
|
||||
# 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
|
||||
@@ -810,7 +859,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
# Group together at the end of the invoice
|
||||
request_show_service_date = period_line
|
||||
elif period_line:
|
||||
description += "\n" + period_line
|
||||
description_p_list.append(FontFallbackParagraph(
|
||||
period_line,
|
||||
self.stylesheet['Fineprint']
|
||||
))
|
||||
|
||||
lines = list(lines)
|
||||
if has_taxes:
|
||||
@@ -819,13 +871,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
net_price=money_filter(net_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((
|
||||
FontFallbackParagraph(
|
||||
self._clean_text(description, tags=['br']),
|
||||
self.stylesheet['Normal']
|
||||
),
|
||||
description_p_list.pop(0),
|
||||
str(len(lines)),
|
||||
localize(tax_rate) + " %",
|
||||
FontFallbackParagraph(
|
||||
@@ -837,23 +889,52 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
self.stylesheet['NormalRight']
|
||||
),
|
||||
))
|
||||
for p in description_p_list:
|
||||
tdata.append((p, "", "", "", ""))
|
||||
tstyledata.append((
|
||||
'SPAN',
|
||||
(0, len(tdata) - 1),
|
||||
(2, len(tdata) - 1),
|
||||
))
|
||||
else:
|
||||
if len(lines) > 1:
|
||||
single_price_line = pgettext('invoice', 'Single price: {price}').format(
|
||||
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((
|
||||
FontFallbackParagraph(
|
||||
self._clean_text(description, tags=['br']),
|
||||
self.stylesheet['Normal']
|
||||
),
|
||||
description_p_list.pop(0),
|
||||
str(len(lines)),
|
||||
FontFallbackParagraph(
|
||||
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
||||
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)
|
||||
grossvalue_map[tax_rate, tax_name] += 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']), '', '', '',
|
||||
money_filter(total, self.invoice.event.currency)
|
||||
])
|
||||
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
|
||||
else:
|
||||
tdata.append([
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
|
||||
money_filter(total, self.invoice.event.currency)
|
||||
])
|
||||
colwidths = [a * doc.width for a in (.65, .20, .15)]
|
||||
|
||||
if not self.invoice.is_cancellation:
|
||||
if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING:
|
||||
|
||||
@@ -19,8 +19,11 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
import dns.resolver
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _, pgettext
|
||||
@@ -123,6 +126,9 @@ class PeppolIdValidator:
|
||||
"9959": ".*",
|
||||
}
|
||||
|
||||
def __init__(self, validate_online=False):
|
||||
self.validate_online = validate_online
|
||||
|
||||
def __call__(self, value):
|
||||
if ":" not in value:
|
||||
raise ValidationError(_("A Peppol participant ID always starts with a prefix, followed by a colon (:)."))
|
||||
@@ -136,6 +142,28 @@ class PeppolIdValidator:
|
||||
raise ValidationError(_("The Peppol participant ID does not match the validation rules for the prefix "
|
||||
"%(number)s. Please reach out to us if you are sure this ID is correct."),
|
||||
params={"number": prefix})
|
||||
|
||||
if self.validate_online:
|
||||
base_hostnames = ['edelivery.tech.ec.europa.eu', 'acc.edelivery.tech.ec.europa.eu']
|
||||
smp_id = base64.b32encode(hashlib.sha256(value.lower().encode()).digest()).decode().rstrip("=")
|
||||
for base_hostname in base_hostnames:
|
||||
smp_domain = f'{smp_id}.iso6523-actorid-upis.{base_hostname}'
|
||||
resolver = dns.resolver.Resolver()
|
||||
try:
|
||||
answers = resolver.resolve(smp_domain, 'NAPTR', lifetime=1.0)
|
||||
if answers:
|
||||
return value
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
# ID not registered, do not set found=True
|
||||
pass
|
||||
except Exception: # noqa
|
||||
# Error likely on our end or infrastructure is down, allow user to proceed
|
||||
return value
|
||||
|
||||
raise ValidationError(
|
||||
_("The Peppol participant ID is not registered on the Peppol network."),
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@@ -155,7 +183,9 @@ class PeppolTransmissionType(TransmissionType):
|
||||
"transmission_peppol_participant_id": forms.CharField(
|
||||
label=_("Peppol participant ID"),
|
||||
validators=[
|
||||
PeppolIdValidator(),
|
||||
PeppolIdValidator(
|
||||
validate_online=True,
|
||||
),
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
+81
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.24 on 2025-11-10 16:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0295_user_is_verified"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="invoice",
|
||||
name="invoice_from_state",
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
]
|
||||
@@ -36,8 +36,9 @@ from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
|
||||
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
||||
SubEventItem, SubEventItemVariation, itempicture_upload_to,
|
||||
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
|
||||
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
|
||||
itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .media import ReusableMedium
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
import binascii
|
||||
import json
|
||||
import operator
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
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.contenttypes.models import ContentType
|
||||
from django.core.exceptions import BadRequest, PermissionDenied
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.crypto import get_random_string, salted_hmac
|
||||
@@ -239,9 +241,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = []
|
||||
MAX_CONFIRMATION_CODE_ATTEMPTS = 10
|
||||
|
||||
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
|
||||
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,
|
||||
verbose_name=_('Full name'))
|
||||
is_active = models.BooleanField(default=True,
|
||||
@@ -353,6 +357,77 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
except SendMailException:
|
||||
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):
|
||||
from pretix.base.services.mail import mail
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from pretix.helpers.celery import get_task_priority
|
||||
from pretix.helpers.json import CustomJSONEncoder
|
||||
|
||||
|
||||
@@ -131,9 +132,15 @@ class LoggingMixin:
|
||||
logentry.save()
|
||||
|
||||
if logentry.notification_type:
|
||||
notify.apply_async(args=(logentry.pk,))
|
||||
notify.apply_async(
|
||||
args=(logentry.pk,),
|
||||
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||
)
|
||||
if logentry.webhook_type:
|
||||
notify_webhooks.apply_async(args=(logentry.pk,))
|
||||
notify_webhooks.apply_async(
|
||||
args=(logentry.pk,),
|
||||
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||
)
|
||||
|
||||
return logentry
|
||||
|
||||
|
||||
@@ -847,7 +847,7 @@ class Event(EventMixin, LoggedModel):
|
||||
from ..signals import event_copy_data
|
||||
from . import (
|
||||
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.
|
||||
@@ -990,6 +990,12 @@ class Event(EventMixin, LoggedModel):
|
||||
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
|
||||
ia.save(force_insert=True)
|
||||
|
||||
if not self.has_subevents and not other.has_subevents:
|
||||
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 = {}
|
||||
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
|
||||
quota_map[q.pk] = q
|
||||
|
||||
@@ -142,6 +142,7 @@ class Invoice(models.Model):
|
||||
invoice_from_name = models.CharField(max_length=190, null=True)
|
||||
invoice_from_zipcode = models.CharField(max_length=190, null=True)
|
||||
invoice_from_city = models.CharField(max_length=190, null=True)
|
||||
invoice_from_state = models.CharField(max_length=190, null=True)
|
||||
invoice_from_country = FastCountryField(null=True)
|
||||
invoice_from_tax_id = models.CharField(max_length=190, null=True)
|
||||
invoice_from_vat_id = models.CharField(max_length=190, null=True)
|
||||
@@ -218,10 +219,23 @@ class Invoice(models.Model):
|
||||
taxidrow = "ABN: %s" % self.invoice_from_tax_id
|
||||
else:
|
||||
taxidrow = pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id
|
||||
|
||||
state_name = ""
|
||||
if self.invoice_from_state:
|
||||
state_name = self.invoice_from_state
|
||||
if str(self.invoice_from_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_from_country)][1] == 'long':
|
||||
try:
|
||||
state_name = pycountry.subdivisions.get(
|
||||
code='{}-{}'.format(self.invoice_from_country, self.invoice_from_state)
|
||||
).name
|
||||
except:
|
||||
pass
|
||||
|
||||
parts = [
|
||||
self.invoice_from_name,
|
||||
self.invoice_from,
|
||||
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
||||
((self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or "") + " " + (state_name or "")).strip(),
|
||||
self.invoice_from_country.name if self.invoice_from_country else "",
|
||||
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
|
||||
taxidrow,
|
||||
@@ -230,10 +244,22 @@ class Invoice(models.Model):
|
||||
|
||||
@property
|
||||
def address_invoice_from(self):
|
||||
state_name = ""
|
||||
if self.invoice_from_state:
|
||||
state_name = self.invoice_from_state
|
||||
if str(self.invoice_from_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_from_country)][1] == 'long':
|
||||
try:
|
||||
state_name = pycountry.subdivisions.get(
|
||||
code='{}-{}'.format(self.invoice_from_country, self.invoice_from_state)
|
||||
).name
|
||||
except:
|
||||
pass
|
||||
|
||||
parts = [
|
||||
self.invoice_from_name,
|
||||
self.invoice_from,
|
||||
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
||||
" ".join(s for s in [self.invoice_from_zipcode, self.invoice_from_city, state_name] if s),
|
||||
self.invoice_from_country.name if self.invoice_from_country else "",
|
||||
]
|
||||
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
||||
|
||||
@@ -505,8 +505,7 @@ class Item(LoggedModel):
|
||||
verbose_name=_("Free price input"),
|
||||
help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
|
||||
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
|
||||
"additional donations for your event. This is currently not supported for products that are "
|
||||
"bought as an add-on to other products.")
|
||||
"additional donations for your event.")
|
||||
)
|
||||
free_price_suggestion = models.DecimalField(
|
||||
verbose_name=_("Suggested price"),
|
||||
@@ -2294,3 +2293,29 @@ class ItemVariationMetaValue(LoggedModel):
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
if hasattr(self, 'item') and self.item and self.item.event.has_subevents:
|
||||
raise ValidationError(_("You cannot use program times on an event series."))
|
||||
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."))
|
||||
|
||||
@@ -35,11 +35,14 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import connections, models
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from pretix.helpers.celery import get_task_priority
|
||||
|
||||
|
||||
class VisibleOnlyManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
@@ -186,7 +189,19 @@ class LogEntry(models.Model):
|
||||
|
||||
to_notify = [o.id for o in objects if o.notification_type]
|
||||
if to_notify:
|
||||
notify.apply_async(args=(to_notify,))
|
||||
organizer_ids = set(o.organizer_id for o in objects if o.notification_type)
|
||||
notify.apply_async(
|
||||
args=(to_notify,),
|
||||
priority=settings.PRIORITY_CELERY_HIGHEST_FUNC(
|
||||
get_task_priority("notifications", oid) for oid in organizer_ids
|
||||
),
|
||||
)
|
||||
to_wh = [o.id for o in objects if o.webhook_type]
|
||||
if to_wh:
|
||||
notify_webhooks.apply_async(args=(to_wh,))
|
||||
organizer_ids = set(o.organizer_id for o in objects if o.webhook_type)
|
||||
notify_webhooks.apply_async(
|
||||
args=(to_wh,),
|
||||
priority=settings.PRIORITY_CELERY_HIGHEST_FUNC(
|
||||
get_task_priority("notifications", oid) for oid in organizer_ids
|
||||
),
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Customer, User
|
||||
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.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
|
||||
default=False,
|
||||
)
|
||||
tax_rounding_mode = models.CharField(
|
||||
max_length=100,
|
||||
choices=ROUNDING_MODES,
|
||||
default="line",
|
||||
)
|
||||
|
||||
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())
|
||||
create = []
|
||||
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]
|
||||
if d:
|
||||
create.append(Transaction(
|
||||
@@ -1272,9 +1278,11 @@ class Order(LockModel, LoggedModel):
|
||||
variation_id=variationid,
|
||||
subevent_id=subeventid,
|
||||
price=price,
|
||||
price_includes_rounding_correction=price_includes_rounding_correction,
|
||||
tax_rate=taxrate,
|
||||
tax_rule_id=taxruleid,
|
||||
tax_value=taxvalue,
|
||||
tax_value_includes_rounding_correction=taxvalue_includes_rounding_correction,
|
||||
tax_code=taxcode,
|
||||
fee_type=feetype,
|
||||
internal_type=internaltype,
|
||||
@@ -1449,7 +1457,22 @@ class QuestionAnswer(models.Model):
|
||||
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.
|
||||
|
||||
@@ -1499,6 +1522,9 @@ class AbstractPosition(models.Model):
|
||||
decimal_places=2, max_digits=13,
|
||||
verbose_name=_("Price")
|
||||
)
|
||||
price_includes_rounding_correction = models.DecimalField(
|
||||
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||
)
|
||||
attendee_name_cached = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Attendee name"),
|
||||
@@ -2272,7 +2298,7 @@ class ActivePositionManager(ScopedManager(organizer='order__event__organizer')._
|
||||
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
|
||||
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,
|
||||
verbose_name=_("Value")
|
||||
)
|
||||
value_includes_rounding_correction = models.DecimalField(
|
||||
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
verbose_name=_("Order"),
|
||||
@@ -2350,6 +2379,9 @@ class OrderFee(models.Model):
|
||||
max_digits=13, decimal_places=2,
|
||||
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)
|
||||
|
||||
all = ScopedManager(organizer='order__event__organizer')
|
||||
@@ -2398,17 +2430,23 @@ class OrderFee(models.Model):
|
||||
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:
|
||||
self.tax_rule = tax_rule
|
||||
|
||||
try:
|
||||
ia = invoice_address or self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
if invoice_address:
|
||||
ia = invoice_address
|
||||
elif hasattr(self, "order"):
|
||||
try:
|
||||
ia = self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
else:
|
||||
ia = None
|
||||
|
||||
if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rule_payment == "default":
|
||||
self.tax_rule = self.order.event.cached_default_tax_rule
|
||||
event = event or self.order.event
|
||||
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:
|
||||
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()
|
||||
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):
|
||||
"""
|
||||
@@ -2522,6 +2578,9 @@ class OrderPosition(AbstractPosition):
|
||||
max_digits=13, decimal_places=2,
|
||||
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)
|
||||
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])
|
||||
else:
|
||||
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:
|
||||
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,
|
||||
verbose_name=_("Price")
|
||||
)
|
||||
price_includes_rounding_correction = models.DecimalField(
|
||||
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=7, decimal_places=2,
|
||||
verbose_name=_('Tax rate')
|
||||
@@ -3044,6 +3113,9 @@ class Transaction(models.Model):
|
||||
max_digits=13, decimal_places=2,
|
||||
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(
|
||||
max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True
|
||||
)
|
||||
@@ -3073,14 +3145,19 @@ class Transaction(models.Model):
|
||||
@staticmethod
|
||||
def key(obj):
|
||||
if isinstance(obj, Transaction):
|
||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
|
||||
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code)
|
||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
|
||||
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):
|
||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
|
||||
obj.tax_rule_id, obj.tax_value, None, None, obj.tax_code)
|
||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
|
||||
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):
|
||||
return (None, None, None, None, obj.value, obj.tax_rate,
|
||||
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code)
|
||||
return (None, None, None, None, obj.value, obj.value_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)
|
||||
raise ValueError('invalid state') # noqa
|
||||
|
||||
@property
|
||||
@@ -3091,6 +3168,14 @@ class Transaction(models.Model):
|
||||
def full_tax_value(self):
|
||||
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):
|
||||
"""
|
||||
@@ -3131,6 +3216,13 @@ class CartPosition(AbstractPosition):
|
||||
max_digits=7, decimal_places=2, default=Decimal('0.00'),
|
||||
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(
|
||||
decimal_places=2, max_digits=13, null=True,
|
||||
)
|
||||
@@ -3171,9 +3263,15 @@ class CartPosition(AbstractPosition):
|
||||
|
||||
@property
|
||||
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)
|
||||
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
|
||||
def sort_key(self):
|
||||
|
||||
@@ -280,13 +280,13 @@ class Seat(models.Model):
|
||||
|
||||
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None,
|
||||
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 .organizer import SalesChannel
|
||||
|
||||
if isinstance(sales_channel, SalesChannel):
|
||||
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
|
||||
opqs = self.orderposition_set.filter(
|
||||
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID],
|
||||
|
||||
@@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.format import format_map
|
||||
from pretix.helpers.money import DecimalTextInput
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1149,12 +1149,16 @@ class FreeOrderProvider(BasePaymentProvider):
|
||||
from .services.cart import get_fees
|
||||
|
||||
cart = get_cart(request)
|
||||
total = get_cart_total(request)
|
||||
|
||||
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:
|
||||
# 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
|
||||
|
||||
def order_change_allowed(self, order: Order) -> bool:
|
||||
@@ -1373,7 +1377,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
execute_payment_needs_user = False
|
||||
verbose_name = _("Gift card")
|
||||
payment_form_class = GiftCardPaymentForm
|
||||
payment_form_template_name = 'pretixcontrol/giftcards/checkout.html'
|
||||
payment_form_template_name = 'pretixpresale/giftcard/checkout.html'
|
||||
|
||||
@cached_property
|
||||
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
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.phone_format import phone_format
|
||||
from pretix.helpers.daterange import datetimerange
|
||||
from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
@@ -490,6 +491,12 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"TIME_FORMAT"
|
||||
) 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", {
|
||||
"label": _("Reusable Medium ID"),
|
||||
"editor_sample": "ABC1234DEF4567",
|
||||
@@ -734,6 +741,16 @@ def get_seat(op: OrderPosition):
|
||||
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):
|
||||
itemcount = defaultdict(int)
|
||||
addons = [p for p in (
|
||||
|
||||
@@ -350,7 +350,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
ocm.add_fee(f)
|
||||
|
||||
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:
|
||||
ocm.commit()
|
||||
refund_amount = payment_refund_sum - o.total
|
||||
|
||||
@@ -66,8 +66,8 @@ from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.checkin import _save_answers
|
||||
from pretix.base.services.locking import LockTimeoutException, lock_objects
|
||||
from pretix.base.services.pricing import (
|
||||
apply_discounts, get_line_price, get_listed_price, get_price,
|
||||
is_included_for_free,
|
||||
apply_discounts, apply_rounding, get_line_price, get_listed_price,
|
||||
get_price, is_included_for_free,
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
@@ -1430,11 +1430,12 @@ class CartManager:
|
||||
)
|
||||
|
||||
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):
|
||||
diff += new_price - cp.price
|
||||
if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None):
|
||||
diff += new_price - cp.gross_price_before_rounding
|
||||
cp.price = new_price
|
||||
cp.price_includes_rounding_correction = Decimal("0.00")
|
||||
cp.discount = discount
|
||||
cp.save(update_fields=['price', 'discount'])
|
||||
cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount'])
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
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 = []
|
||||
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,
|
||||
total=total, positions=positions, payment_requests=payments):
|
||||
positions=positions, total=total, payment_requests=payments):
|
||||
if 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:
|
||||
total_remaining = total
|
||||
payments_assigned = Decimal("0.00")
|
||||
for p in payments:
|
||||
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
||||
# places in the code base:
|
||||
# - pretix.base.services.cart.get_fees
|
||||
# - pretix.base.services.orders._get_fees
|
||||
# - 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
|
||||
|
||||
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']):
|
||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||
|
||||
@@ -1525,28 +1549,32 @@ def get_fees(event, request, total, invoice_address, payments, positions):
|
||||
continue
|
||||
|
||||
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 event.settings.tax_rule_payment == "default":
|
||||
payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero()
|
||||
else:
|
||||
payment_fee_tax_rule = TaxRule.zero()
|
||||
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,
|
||||
value=payment_fee,
|
||||
tax_rate=payment_fee_tax.rate,
|
||||
tax_value=payment_fee_tax.tax,
|
||||
tax_code=payment_fee_tax.code,
|
||||
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
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
||||
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
||||
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
|
||||
invoice.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
|
||||
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
|
||||
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
|
||||
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
|
||||
@@ -258,9 +259,15 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
if resp:
|
||||
desc += "<br/>" + resp
|
||||
|
||||
for answ in p.answers.all():
|
||||
if not answ.question.print_on_invoice:
|
||||
continue
|
||||
answers_qs = p.answers.filter(
|
||||
question__print_on_invoice=True
|
||||
).select_related(
|
||||
'question'
|
||||
).order_by(
|
||||
'question__position',
|
||||
'question__id'
|
||||
)
|
||||
for answ in answers_qs:
|
||||
desc += "<br />{}{} {}".format(
|
||||
answ.question.question,
|
||||
"" if str(answ.question.question).endswith("?") else ":",
|
||||
@@ -453,6 +460,7 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
|
||||
cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
||||
cancellation.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
||||
cancellation.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
|
||||
cancellation.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
|
||||
cancellation.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
|
||||
cancellation.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
|
||||
cancellation.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
|
||||
@@ -556,6 +564,7 @@ def build_preview_invoice_pdf(event):
|
||||
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
||||
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
||||
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
|
||||
invoice.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
|
||||
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
|
||||
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
|
||||
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
|
||||
@@ -687,7 +696,7 @@ def retry_stuck_invoices(sender, **kwargs):
|
||||
with transaction.atomic():
|
||||
qs = Invoice.objects.filter(
|
||||
transmission_status=Invoice.TRANSMISSION_STATUS_INFLIGHT,
|
||||
transmission_date__lte=now() - timedelta(hours=24),
|
||||
transmission_date__lte=now() - timedelta(hours=48),
|
||||
).select_for_update(
|
||||
of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked
|
||||
)
|
||||
|
||||
@@ -222,7 +222,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
'invoice_company': ''
|
||||
})
|
||||
renderer = ClassicMailRenderer(None, organizer)
|
||||
content_plain = body_plain = render_mail(template, context)
|
||||
body_plain = render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN)
|
||||
subject = str(subject).format_map(TolerantDict(context))
|
||||
sender = (
|
||||
sender or
|
||||
@@ -316,6 +316,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
|
||||
with override(timezone):
|
||||
try:
|
||||
content_plain = render_mail(template, context, placeholder_mode=None)
|
||||
if plain_text_only:
|
||||
body_html = None
|
||||
elif 'context' in inspect.signature(renderer.render).parameters:
|
||||
@@ -751,11 +752,11 @@ def mail_send(*args, **kwargs):
|
||||
mail_send_task.apply_async(args=args, kwargs=kwargs)
|
||||
|
||||
|
||||
def render_mail(template, context):
|
||||
def render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN):
|
||||
if isinstance(template, LazyI18nString):
|
||||
body = str(template)
|
||||
if context:
|
||||
body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH)
|
||||
if context and placeholder_mode:
|
||||
body = format_map(body, context, mode=placeholder_mode)
|
||||
else:
|
||||
tpl = get_template(template)
|
||||
body = tpl.render(context)
|
||||
|
||||
@@ -32,6 +32,7 @@ from pretix.base.services.mail import mail_send_task
|
||||
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
||||
from pretix.base.signals import notification
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.celery import get_task_priority
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
|
||||
@@ -88,12 +89,18 @@ def notify(logentry_ids: list):
|
||||
for um, enabled in notify_specific.items():
|
||||
user, method = um
|
||||
if enabled:
|
||||
send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method))
|
||||
send_notification.apply_async(
|
||||
args=(logentry.id, notification_type.action_type, user.pk, method),
|
||||
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||
)
|
||||
|
||||
for um, enabled in notify_global.items():
|
||||
user, method = um
|
||||
if enabled and um not in notify_specific:
|
||||
send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method))
|
||||
send_notification.apply_async(
|
||||
args=(logentry.id, notification_type.action_type, user.pk, method),
|
||||
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||
)
|
||||
|
||||
notification.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ from pretix.base.services.memberships import (
|
||||
create_membership, validate_memberships_in_order,
|
||||
)
|
||||
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.tasks import ProfiledEventTask, ProfiledTask
|
||||
@@ -146,6 +146,10 @@ error_messages = {
|
||||
'race_condition': gettext_lazy("This order was changed by someone else simultaneously. Please check if your "
|
||||
"changes are still accurate and try again."),
|
||||
'empty': gettext_lazy("Your cart is empty."),
|
||||
'max_items': ngettext_lazy(
|
||||
"You cannot select more than %s item per order.",
|
||||
"You cannot select more than %s items per order."
|
||||
),
|
||||
'max_items_per_product': ngettext_lazy(
|
||||
"You cannot select more than %(max)s item of the product %(product)s. We removed the surplus items from your cart.",
|
||||
"You cannot select more than %(max)s items of the product %(product)s. We removed the surplus items from your cart.",
|
||||
@@ -763,6 +767,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
||||
shared_lock_objects=[event]
|
||||
)
|
||||
|
||||
# Check maximum order size
|
||||
limit = min(int(event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE)
|
||||
if sum(1 for cp in sorted_positions if not cp.addon_to) > limit:
|
||||
err = err or (error_messages['max_items'] % limit)
|
||||
|
||||
# Check availability
|
||||
for i, cp in enumerate(sorted_positions):
|
||||
if cp.pk in deleted_positions:
|
||||
@@ -947,10 +956,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):
|
||||
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_includes_rounding_correction = Decimal("0.00")
|
||||
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
|
||||
# parent position, which can screw up ordering later since the system sees inconsistent data.
|
||||
@@ -973,10 +983,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
||||
raise OrderError(err)
|
||||
|
||||
|
||||
def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
|
||||
meta_info: dict, event: Event, require_approval=False):
|
||||
def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
|
||||
meta_info: dict, event: Event, require_approval=False):
|
||||
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
|
||||
for p in payment_requests:
|
||||
@@ -987,40 +998,53 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
|
||||
meta_info=meta_info, positions=positions, gift_cards=gift_cards):
|
||||
if 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:
|
||||
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
||||
# places in the code base:
|
||||
# - pretix.base.services.cart.get_fees
|
||||
# - pretix.base.services.orders._get_fees
|
||||
# - 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')
|
||||
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']):
|
||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||
|
||||
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:
|
||||
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
|
||||
internal_type=p['pprov'].identifier)
|
||||
pf._calculate_tax(invoice_address=address, event=event)
|
||||
fees.append(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."))
|
||||
|
||||
return fees
|
||||
@@ -1029,7 +1053,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,
|
||||
payment_requests: List[dict], sales_channel: SalesChannel, locale: str=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 = []
|
||||
|
||||
try:
|
||||
@@ -1038,10 +1062,13 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
|
||||
raise OrderError(e.message)
|
||||
|
||||
require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
|
||||
|
||||
# Final calculation of fees, also performs final rounding
|
||||
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:
|
||||
raise OrderError(error_messages['country_blocked'])
|
||||
|
||||
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
||||
|
||||
order = Order(
|
||||
@@ -1059,6 +1086,7 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
|
||||
sales_channel=sales_channel,
|
||||
customer=customer,
|
||||
valid_if_pending=valid_if_pending,
|
||||
tax_rounding_mode=tax_rounding_mode or event.settings.tax_rounding,
|
||||
)
|
||||
if customer:
|
||||
order.email_known_to_work = customer.is_verified
|
||||
@@ -1073,12 +1101,6 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
|
||||
|
||||
for fee in fees:
|
||||
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()
|
||||
|
||||
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
|
||||
@@ -1167,7 +1189,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],
|
||||
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:
|
||||
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
|
||||
if not p['pprov']:
|
||||
@@ -1273,6 +1295,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
|
||||
customer=customer,
|
||||
valid_if_pending=valid_if_pending,
|
||||
api_meta=api_meta,
|
||||
tax_rounding_mode=tax_rounding_mode,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1656,15 +1679,16 @@ class OrderChangeManager:
|
||||
AddBlockOperation = namedtuple('AddBlockOperation', ('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.user = user
|
||||
self.auth = auth
|
||||
self.event = order.event
|
||||
self.split_order = None
|
||||
self.reissue_invoice = reissue_invoice
|
||||
self.allow_blocked_seats = allow_blocked_seats
|
||||
self._committed = False
|
||||
self._totaldiff = 0
|
||||
self._totaldiff_guesstimate = 0
|
||||
self._quotadiff = Counter()
|
||||
self._seatdiff = Counter()
|
||||
self._operations = []
|
||||
@@ -1781,7 +1805,7 @@ class OrderChangeManager:
|
||||
if position.issued_gift_cards.exists():
|
||||
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'):
|
||||
self._invoice_dirty = True
|
||||
@@ -1826,29 +1850,29 @@ class OrderChangeManager:
|
||||
else:
|
||||
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)
|
||||
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._invoice_dirty = True
|
||||
|
||||
def cancel_fee(self, fee: OrderFee):
|
||||
self._totaldiff -= fee.value
|
||||
self._totaldiff_guesstimate -= fee.value
|
||||
self._operations.append(self.CancelFeeOperation(fee, -fee.value))
|
||||
self._invoice_dirty = True
|
||||
|
||||
def add_fee(self, fee: OrderFee):
|
||||
self._totaldiff += fee.value
|
||||
self._totaldiff_guesstimate += fee.value
|
||||
self._invoice_dirty = True
|
||||
self._operations.append(self.AddFeeOperation(fee, fee.value))
|
||||
|
||||
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,
|
||||
force_fixed_gross_price=True)
|
||||
self._totaldiff += value.gross - fee.value
|
||||
self._totaldiff_guesstimate += value.gross - fee.value
|
||||
self._invoice_dirty = True
|
||||
self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value))
|
||||
|
||||
def cancel(self, position: OrderPosition):
|
||||
self._totaldiff -= position.price
|
||||
self._totaldiff_guesstimate -= position.price
|
||||
self._quotadiff.subtract(position.quotas)
|
||||
self._operations.append(self.CancelOperation(position, -position.price))
|
||||
if position.seat:
|
||||
@@ -1914,7 +1938,7 @@ class OrderChangeManager:
|
||||
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
|
||||
self._invoice_dirty = True
|
||||
|
||||
self._totaldiff += price.gross
|
||||
self._totaldiff_guesstimate += price.gross
|
||||
self._quotadiff.update(new_quotas)
|
||||
if seat:
|
||||
self._seatdiff.update([seat])
|
||||
@@ -2183,7 +2207,7 @@ class OrderChangeManager:
|
||||
for seat, diff in self._seatdiff.items():
|
||||
if diff <= 0:
|
||||
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))
|
||||
|
||||
if self.event.has_subevents:
|
||||
@@ -2210,8 +2234,8 @@ class OrderChangeManager:
|
||||
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))
|
||||
|
||||
def _check_paid_price_change(self):
|
||||
if self.order.status == Order.STATUS_PAID and self._totaldiff > 0:
|
||||
def _check_paid_price_change(self, totaldiff):
|
||||
if self.order.status == Order.STATUS_PAID and totaldiff > 0:
|
||||
if self.order.pending_sum > Decimal('0.00'):
|
||||
self.order.status = Order.STATUS_PENDING
|
||||
self.order.set_expires(
|
||||
@@ -2219,7 +2243,7 @@ class OrderChangeManager:
|
||||
self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
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:
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
@@ -2246,7 +2270,7 @@ class OrderChangeManager:
|
||||
user=self.user,
|
||||
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:
|
||||
try:
|
||||
self.open_payment.payment_provider.cancel_payment(self.open_payment)
|
||||
@@ -2266,11 +2290,11 @@ class OrderChangeManager:
|
||||
auth=self.auth,
|
||||
)
|
||||
|
||||
def _check_paid_to_free(self):
|
||||
if self.event.currency == 'XXX' and self.order.total + self._totaldiff > Decimal("0.00"):
|
||||
def _check_paid_to_free(self, totaldiff):
|
||||
if self.event.currency == 'XXX' and self.order.total + totaldiff > Decimal("0.00"):
|
||||
raise OrderError(error_messages['currency_XXX'])
|
||||
|
||||
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
|
||||
if 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():
|
||||
# The order is completely empty now, so we cancel it.
|
||||
self.order.status = Order.STATUS_CANCELED
|
||||
@@ -2278,7 +2302,7 @@ class OrderChangeManager:
|
||||
order_canceled.send(self.order.event, order=self.order)
|
||||
elif self.order.status != Order.STATUS_CANCELED:
|
||||
# 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)
|
||||
p = self.order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
@@ -2407,10 +2431,15 @@ class OrderChangeManager:
|
||||
'new_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_value = op.price.tax
|
||||
position.tax_value_includes_rounding_correction = Decimal("0.00")
|
||||
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):
|
||||
if isinstance(op.position, OrderPosition):
|
||||
position = position_cache.setdefault(op.position.pk, op.position)
|
||||
@@ -2677,14 +2706,18 @@ class OrderChangeManager:
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
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):
|
||||
new_fee = modelcopy(fee)
|
||||
new_fee.pk = None
|
||||
new_fee.order = split_order
|
||||
split_order.total += new_fee.value
|
||||
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:
|
||||
pp = self._get_payment_provider()
|
||||
@@ -2697,9 +2730,27 @@ class OrderChangeManager:
|
||||
fee._calculate_tax()
|
||||
if payment_fee != 0:
|
||||
fee.save()
|
||||
fees.append(fee)
|
||||
elif fee.pk:
|
||||
if fee in fees:
|
||||
fees.remove(fee)
|
||||
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()])
|
||||
offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total)
|
||||
@@ -2759,9 +2810,12 @@ class OrderChangeManager:
|
||||
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||
return payment_sum - refund_sum
|
||||
|
||||
def _recalculate_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()])
|
||||
def _recalculate_rounding_total_and_payment_fee(self):
|
||||
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')
|
||||
fee_changed = False
|
||||
if self.open_payment:
|
||||
current_fee = Decimal('0.00')
|
||||
fee = None
|
||||
@@ -2789,14 +2843,32 @@ class OrderChangeManager:
|
||||
fee.value = payment_fee
|
||||
fee._calculate_tax()
|
||||
fee.save()
|
||||
fee_changed = True
|
||||
if not self.open_payment.fee:
|
||||
self.open_payment.fee = fee
|
||||
self.open_payment.save(update_fields=['fee'])
|
||||
elif fee and not fee.canceled:
|
||||
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()
|
||||
return total
|
||||
|
||||
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:
|
||||
@@ -2806,23 +2878,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):
|
||||
i = self.order.invoices.filter(is_cancellation=False).last()
|
||||
if self.reissue_invoice and self._invoice_dirty:
|
||||
@@ -2953,6 +3008,13 @@ class OrderChangeManager:
|
||||
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):
|
||||
if self._committed:
|
||||
# an order change can only be committed once
|
||||
@@ -2968,8 +3030,6 @@ class OrderChangeManager:
|
||||
# so it's dangerous to keep the cache around.
|
||||
self.order._prefetched_objects_cache = {}
|
||||
|
||||
# finally, incorporate difference in payment fees
|
||||
self._payment_fee_diff()
|
||||
self._check_order_size()
|
||||
|
||||
with transaction.atomic():
|
||||
@@ -2977,6 +3037,7 @@ class OrderChangeManager:
|
||||
if locked_instance.last_modified != self.order.last_modified:
|
||||
raise OrderError(error_messages['race_condition'])
|
||||
|
||||
original_total = self.order.total
|
||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
if check_quotas:
|
||||
self._check_quotas()
|
||||
@@ -2988,9 +3049,10 @@ class OrderChangeManager:
|
||||
self._perform_operations()
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
||||
self._recalculate_total_and_payment_fee()
|
||||
self._check_paid_price_change()
|
||||
self._check_paid_to_free()
|
||||
new_total = self._recalculate_rounding_total_and_payment_fee()
|
||||
totaldiff = new_total - original_total
|
||||
self._check_paid_price_change(totaldiff)
|
||||
self._check_paid_to_free(totaldiff)
|
||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
self._reissue_invoice()
|
||||
self._clear_tickets_cache()
|
||||
@@ -3209,6 +3271,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!')
|
||||
|
||||
oldtotal = order.total
|
||||
already_paid = order.payment_refund_sum
|
||||
e = OrderPayment.objects.filter(fee=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
OrderPayment.PAYMENT_STATE_REFUNDED))
|
||||
open_fees = list(
|
||||
@@ -3225,19 +3288,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)
|
||||
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(
|
||||
order.pending_sum - old_fee if amount is None else amount
|
||||
pending_sum_without_fee if amount is None else amount
|
||||
)
|
||||
if new_fee:
|
||||
fee.value = new_fee
|
||||
fee.internal_type = payment_provider.identifier
|
||||
fee._calculate_tax()
|
||||
if fee in fees:
|
||||
fees.remove(fee)
|
||||
# "Update instance in the fees array
|
||||
fees.append(fee)
|
||||
fee.save()
|
||||
else:
|
||||
if fee in fees:
|
||||
fees.remove(fee)
|
||||
if fee.pk:
|
||||
fee.delete()
|
||||
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
|
||||
if new_payment:
|
||||
lp = order.payments.select_for_update(of=OF_SELF).exclude(pk=new_payment.pk).last()
|
||||
@@ -3264,7 +3354,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'])
|
||||
|
||||
if not new_payment:
|
||||
|
||||
@@ -26,7 +26,7 @@ from decimal import Decimal
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
from django.utils.html import escape, mark_safe
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -123,6 +123,10 @@ class BaseRichTextPlaceholder(BaseTextPlaceholder):
|
||||
def identifier(self):
|
||||
return self._identifier
|
||||
|
||||
@property
|
||||
def allowed_in_plain_content(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def required_context(self):
|
||||
return self._args
|
||||
@@ -194,6 +198,33 @@ class SimpleButtonPlaceholder(BaseRichTextPlaceholder):
|
||||
return f'{text}: {url}'
|
||||
|
||||
|
||||
class MarkdownTextPlaceholder(BaseRichTextPlaceholder):
|
||||
def __init__(self, identifier, args, func, sample, inline):
|
||||
super().__init__(identifier, args)
|
||||
self._func = func
|
||||
self._sample = sample
|
||||
self._snippet = inline
|
||||
|
||||
@property
|
||||
def allowed_in_plain_content(self):
|
||||
return self._snippet
|
||||
|
||||
def render_plain(self, **context):
|
||||
return self._func(**{k: context[k] for k in self._args})
|
||||
|
||||
def render_html(self, **context):
|
||||
return mark_safe(markdown_compile_email(self.render_plain(**context), snippet=self._snippet))
|
||||
|
||||
def render_sample_plain(self, event):
|
||||
if callable(self._sample):
|
||||
return self._sample(event)
|
||||
else:
|
||||
return self._sample
|
||||
|
||||
def render_sample_html(self, event):
|
||||
return mark_safe(markdown_compile_email(self.render_sample_plain(event), snippet=self._snippet))
|
||||
|
||||
|
||||
class PlaceholderContext(SafeFormatter):
|
||||
"""
|
||||
Holds the contextual arguments and corresponding list of available placeholders for formatting
|
||||
@@ -574,7 +605,7 @@ def base_placeholders(sender, **kwargs):
|
||||
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
|
||||
_('Sample Corporation')
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
MarkdownTextPlaceholder(
|
||||
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
order.full_code,
|
||||
@@ -604,6 +635,7 @@ def base_placeholders(sender, **kwargs):
|
||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
|
||||
]
|
||||
),
|
||||
inline=False,
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
|
||||
@@ -618,12 +650,13 @@ def base_placeholders(sender, **kwargs):
|
||||
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
|
||||
'68CYU2H6ZTP3WLK5'
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
MarkdownTextPlaceholder(
|
||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
|
||||
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
|
||||
'68CYU2H6ZTP3WLK5 \n7MB94KKPVEPSMVF2',
|
||||
inline=False,
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
MarkdownTextPlaceholder(
|
||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||
'voucher_url_list', ['event', 'voucher_list'],
|
||||
lambda event, voucher_list: ' \n'.join([
|
||||
@@ -638,6 +671,7 @@ def base_placeholders(sender, **kwargs):
|
||||
) + '?voucher=' + c
|
||||
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
|
||||
]),
|
||||
inline=False,
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||
@@ -656,13 +690,13 @@ def base_placeholders(sender, **kwargs):
|
||||
'comment', ['comment'], lambda comment: comment,
|
||||
_('An individual text with a reason can be inserted here.'),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
MarkdownTextPlaceholder(
|
||||
'payment_info', ['order', 'payments'], _placeholder_payments,
|
||||
_('The amount has been charged to your card.'),
|
||||
_('The amount has been charged to your card.'), inline=False,
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
MarkdownTextPlaceholder(
|
||||
'payment_info', ['payment_info'], lambda payment_info: payment_info,
|
||||
_('Please transfer money to this bank account: 9999-9999-9999-9999'),
|
||||
_('Please transfer money to this bank account: 9999-9999-9999-9999'), inline=False,
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'attendee_name', ['position'], lambda position: position.attendee_name,
|
||||
@@ -719,13 +753,13 @@ def base_placeholders(sender, **kwargs):
|
||||
))
|
||||
|
||||
for k, v in sender.meta_data.items():
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
ph.append(MarkdownTextPlaceholder(
|
||||
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
|
||||
v
|
||||
v, inline=True,
|
||||
))
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
ph.append(MarkdownTextPlaceholder(
|
||||
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
|
||||
v
|
||||
v, inline=True,
|
||||
))
|
||||
|
||||
return ph
|
||||
@@ -753,7 +787,7 @@ def get_available_placeholders(event, base_parameters, rich=False):
|
||||
if not isinstance(val, (list, tuple)):
|
||||
val = [val]
|
||||
for v in val:
|
||||
if isinstance(v, BaseRichTextPlaceholder) and not rich:
|
||||
if isinstance(v, BaseRichTextPlaceholder) and not rich and not v.allowed_in_plain_content:
|
||||
continue
|
||||
if all(rp in base_parameters for rp in v.required_context):
|
||||
params[v.identifier] = v
|
||||
@@ -775,13 +809,13 @@ def get_sample_context(event, context_parameters, rich=True):
|
||||
)
|
||||
)
|
||||
elif str(sample).strip().startswith('* ') or str(sample).startswith(' '):
|
||||
context_dict[k] = '<div class="placeholder" title="{}">{}</div>'.format(
|
||||
context_dict[k] = mark_safe('<div class="placeholder" title="{}">{}</div>'.format(
|
||||
lbl,
|
||||
markdown_compile_email(str(sample))
|
||||
)
|
||||
))
|
||||
else:
|
||||
context_dict[k] = '<span class="placeholder" title="{}">{}</span>'.format(
|
||||
context_dict[k] = mark_safe('<span class="placeholder" title="{}">{}</span>'.format(
|
||||
lbl,
|
||||
escape(sample)
|
||||
)
|
||||
))
|
||||
return context_dict
|
||||
|
||||
@@ -23,15 +23,17 @@ import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
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.conf import settings
|
||||
from django.db.models import Q
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import (
|
||||
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
|
||||
SalesChannel, Voucher,
|
||||
AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
|
||||
ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
|
||||
)
|
||||
from pretix.base.models.discount import Discount, PositionInfo
|
||||
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,
|
||||
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:
|
||||
tax_rule = TaxRule(
|
||||
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,
|
||||
subtract_from_gross=bundled_sum)
|
||||
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,
|
||||
subtract_from_gross=bundled_sum)
|
||||
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],
|
||||
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
|
||||
|
||||
@@ -203,3 +207,121 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
||||
new_prices.update(result)
|
||||
|
||||
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 or "")
|
||||
|
||||
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
|
||||
|
||||
@@ -40,6 +40,7 @@ from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import pycountry
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -77,6 +78,13 @@ from pretix.control.forms import (
|
||||
)
|
||||
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():
|
||||
allcountries = list(CachedCountries())
|
||||
@@ -324,7 +332,7 @@ DEFAULTS = {
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'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 "
|
||||
"paid."),
|
||||
|
||||
@@ -465,6 +473,25 @@ DEFAULTS = {
|
||||
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': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
@@ -663,6 +690,7 @@ DEFAULTS = {
|
||||
label=_("Minimum length of invoice number after prefix"),
|
||||
help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."),
|
||||
max_value=12,
|
||||
min_value=1,
|
||||
required=True,
|
||||
)
|
||||
},
|
||||
@@ -698,8 +726,9 @@ DEFAULTS = {
|
||||
message=lazy(lambda *args: _('Please only use the characters {allowed} in this field.').format(
|
||||
allowed='A-Z, a-z, 0-9, -./:#'
|
||||
), str)()
|
||||
)
|
||||
),
|
||||
],
|
||||
max_length=155,
|
||||
)
|
||||
},
|
||||
'invoice_numbers_prefix_cancellations': {
|
||||
@@ -720,8 +749,9 @@ DEFAULTS = {
|
||||
message=lazy(lambda *args: _('Please only use the characters {allowed} in this field.').format(
|
||||
allowed='A-Z, a-z, 0-9, -./:#'
|
||||
), str)()
|
||||
)
|
||||
),
|
||||
],
|
||||
max_length=155,
|
||||
)
|
||||
},
|
||||
'invoice_renderer_highlight_order_code': {
|
||||
@@ -1176,6 +1206,7 @@ DEFAULTS = {
|
||||
'form_class': forms.CharField,
|
||||
'serializer_class': serializers.CharField,
|
||||
'form_kwargs': dict(
|
||||
max_length=190,
|
||||
label=_("Company name"),
|
||||
)
|
||||
},
|
||||
@@ -1189,6 +1220,7 @@ DEFAULTS = {
|
||||
'placeholder': '12345'
|
||||
}),
|
||||
label=_("ZIP code"),
|
||||
max_length=190,
|
||||
)
|
||||
},
|
||||
'invoice_address_from_city': {
|
||||
@@ -1201,15 +1233,35 @@ DEFAULTS = {
|
||||
'placeholder': _('Random City')
|
||||
}),
|
||||
label=_("City"),
|
||||
max_length=190,
|
||||
)
|
||||
},
|
||||
'invoice_address_from_state': {
|
||||
'default': '',
|
||||
'type': str,
|
||||
'form_class': forms.ChoiceField,
|
||||
'serializer_class': serializers.ChoiceField,
|
||||
'serializer_kwargs': {
|
||||
'choices': [('', '')],
|
||||
},
|
||||
'form_kwargs': {
|
||||
"label": pgettext_lazy('address', 'State'),
|
||||
'choices': [('', '')],
|
||||
},
|
||||
},
|
||||
'invoice_address_from_country': {
|
||||
'default': '',
|
||||
'type': str,
|
||||
'form_class': forms.ChoiceField,
|
||||
'serializer_class': serializers.ChoiceField,
|
||||
'serializer_kwargs': lambda: dict(**country_choice_kwargs()),
|
||||
'form_kwargs': lambda: dict(label=_('Country'), **country_choice_kwargs()),
|
||||
'form_kwargs': lambda: dict(
|
||||
label=_('Country'),
|
||||
widget=forms.Select(attrs={
|
||||
'data-trigger-address-info': 'on',
|
||||
}),
|
||||
**country_choice_kwargs()
|
||||
),
|
||||
},
|
||||
'invoice_address_from_tax_id': {
|
||||
'default': '',
|
||||
@@ -1218,7 +1270,8 @@ DEFAULTS = {
|
||||
'serializer_class': serializers.CharField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Domestic tax ID"),
|
||||
help_text=_("e.g. tax number in Germany, ABN in Australia, …")
|
||||
help_text=_("e.g. tax number in Germany, ABN in Australia, …"),
|
||||
max_length=190,
|
||||
)
|
||||
},
|
||||
'invoice_address_from_vat_id': {
|
||||
@@ -1228,6 +1281,7 @@ DEFAULTS = {
|
||||
'serializer_class': serializers.CharField,
|
||||
'form_kwargs': dict(
|
||||
label=_("EU VAT ID"),
|
||||
max_length=190,
|
||||
)
|
||||
},
|
||||
'invoice_introductory_text': {
|
||||
@@ -3945,6 +3999,20 @@ def validate_event_settings(event, settings_dict):
|
||||
raise ValidationError({
|
||||
'invoice_address_company_required': _('You have to require invoice addresses to require for company names.')
|
||||
})
|
||||
if settings_dict.get('invoice_address_from_state') and settings_dict.get('invoice_address_from_country'):
|
||||
cc = str(settings_dict.get('invoice_address_from_country'))
|
||||
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
raise ValidationError(
|
||||
{'invoice_address_from_state': ['States are not supported in country "{}".'.format(cc)]}
|
||||
)
|
||||
if not pycountry.subdivisions.get(code=cc + '-' + settings_dict.get('invoice_address_from_state')):
|
||||
raise ValidationError(
|
||||
{'invoice_address_from_state': [
|
||||
'"{}" is not a known subdivision of the country "{}".'.format(
|
||||
settings_dict.get('invoice_address_from_state'), cc
|
||||
)
|
||||
]}
|
||||
)
|
||||
|
||||
payment_term_last = settings_dict.get('payment_term_last')
|
||||
if payment_term_last and event.presale_end:
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
from ast import literal_eval
|
||||
from collections import namedtuple
|
||||
from datetime import datetime
|
||||
from typing import Union
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
||||
from pretix.base.models import Event
|
||||
from pretix.control.forms import SplitDateTimeField
|
||||
from pretix.control.forms.widgets import Select2
|
||||
|
||||
SubEventSelection = namedtuple(
|
||||
typename='SubEventSelection',
|
||||
field_names=['selection', 'subevents', 'start', 'end', ],
|
||||
defaults=['subevent', None, None, None],
|
||||
)
|
||||
|
||||
|
||||
subeventselectionparts = namedtuple(
|
||||
typename='subeventselectionparts',
|
||||
field_names=['selection', 'subevents', 'start', 'end']
|
||||
)
|
||||
|
||||
|
||||
class SubEventSelectionWrapper:
|
||||
def __init__(self, data: Union[None, SubEventSelection]):
|
||||
self.data = data
|
||||
|
||||
def get_queryset(self, event: Event):
|
||||
if self.data.selection == 'subevent':
|
||||
if self.data.subevents is None:
|
||||
return event.subevents.all()
|
||||
else:
|
||||
return event.subevents.filter(pk=self.data.subevents)
|
||||
elif self.data.selection == 'timerange':
|
||||
if self.data.start and self.data.end:
|
||||
return event.subevents.filter(date_from__lte=self.data.start,
|
||||
date_from__gte=self.data.end)
|
||||
elif self.data.start:
|
||||
return event.subevents.filter(date_from__gte=self.data.start)
|
||||
elif self.data.end:
|
||||
return event.subevents.filter(date_from__lte=self.data.end)
|
||||
return event.subevents.all()
|
||||
|
||||
def to_string(self) -> str:
|
||||
if self.data:
|
||||
if self.data.selection == 'subevent':
|
||||
return 'SUBEVENT/pk/{}'.format(self.data.subevents.pk)
|
||||
elif self.data.selection == 'timerange':
|
||||
if self.data.start and self.data.end:
|
||||
return 'SUBEVENT/range/{}/{}'.format(self.data.start.isoformat(), self.data.end.isoformat())
|
||||
elif self.data.start:
|
||||
return 'SUBEVENT/from/{}'.format(self.data.start)
|
||||
elif self.data.end:
|
||||
return 'SUBEVENT/to/{}'.format(self.data.end)
|
||||
return 'SUBEVENT'
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, input: str):
|
||||
data = SubEventSelection(selection='subevent')
|
||||
|
||||
if input.startswith('SUBEVENT'):
|
||||
parts = input.split('/')
|
||||
if len(parts) == 1:
|
||||
data = SubEventSelection(selection='subevent')
|
||||
elif parts[1] == 'pk':
|
||||
data = SubEventSelection(
|
||||
selection='subevent',
|
||||
subevents=literal_eval(parts[2])
|
||||
)
|
||||
elif parts[1] == 'range':
|
||||
data = SubEventSelection(
|
||||
selection="timerange",
|
||||
start=datetime.fromisoformat(parts[2]),
|
||||
end=datetime.fromisoformat(parts[3]),
|
||||
)
|
||||
elif parts[1] == 'from':
|
||||
data = SubEventSelection(
|
||||
selection="timerange",
|
||||
start=datetime.fromisoformat(parts[2]),
|
||||
)
|
||||
elif parts[1] == 'to':
|
||||
data = SubEventSelection(
|
||||
selection="timerange",
|
||||
end=datetime.fromisoformat(parts[3]),
|
||||
)
|
||||
return SubEventSelectionWrapper(
|
||||
data=data
|
||||
)
|
||||
|
||||
|
||||
class SubeventSelectionWidget(forms.MultiWidget):
|
||||
template_name = 'pretixcontrol/forms/widgets/subeventselection.html'
|
||||
parts = SubEventSelection
|
||||
|
||||
def __init__(self, event: Event, status_choices, subevent_choices, *args, **kwargs):
|
||||
widgets = subeventselectionparts(
|
||||
selection=forms.RadioSelect(
|
||||
choices=status_choices,
|
||||
|
||||
),
|
||||
subevents=Select2(
|
||||
attrs={
|
||||
'class': 'simple-subevent-choice',
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'All dates')
|
||||
},
|
||||
),
|
||||
start=SplitDateTimePickerWidget(),
|
||||
end=SplitDateTimePickerWidget(),
|
||||
|
||||
)
|
||||
widgets.subevents.choices = subevent_choices
|
||||
super().__init__(widgets=widgets, *args, **kwargs)
|
||||
|
||||
def decompress(self, value):
|
||||
|
||||
if isinstance(value, str):
|
||||
value = SubEventSelectionWrapper.from_string(value)
|
||||
if isinstance(value, subeventselectionparts):
|
||||
return value
|
||||
|
||||
return subeventselectionparts(selection='subevent', start=None, end=None, subevents=None)
|
||||
|
||||
|
||||
class SubeventSelectionField(forms.MultiValueField):
|
||||
widget = SubeventSelectionWidget
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
|
||||
choices = [
|
||||
("subevent", _("Subevent")),
|
||||
("timerange", _("Timerange"))
|
||||
]
|
||||
|
||||
fields = SubEventSelection(
|
||||
selection=forms.ChoiceField(
|
||||
choices=choices,
|
||||
required=True,
|
||||
),
|
||||
subevents=forms.ModelChoiceField(
|
||||
required=False,
|
||||
queryset=self.event.subevents,
|
||||
empty_label=pgettext_lazy('subevent', 'All dates')
|
||||
),
|
||||
start=SplitDateTimeField(
|
||||
required=False,
|
||||
),
|
||||
end=SplitDateTimeField(
|
||||
required=False,
|
||||
),
|
||||
)
|
||||
|
||||
kwargs['widget'] = SubeventSelectionWidget(
|
||||
event=self.event,
|
||||
status_choices=choices,
|
||||
subevent_choices=fields.subevents.widget.choices,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
|
||||
def compress(self, data_list):
|
||||
if not data_list:
|
||||
return None
|
||||
return SubEventSelectionWrapper(data=SubEventSelection(*data_list)).to_string()
|
||||
|
||||
def clean(self, value):
|
||||
data = subeventselectionparts(*value)
|
||||
|
||||
if data.selection == "timerange":
|
||||
if (data.start != ["", ""] and data.end != ["", ""]) and data.end < data.start:
|
||||
raise ValidationError(_("The end date must be after the start date."))
|
||||
|
||||
if (data.start == ["", ""]) and (data.end == ["", ""]):
|
||||
raise ValidationError(_('At least one of start and end must be specified.'))
|
||||
|
||||
return super().clean(value)
|
||||
@@ -44,6 +44,7 @@ from django.conf import settings
|
||||
from django.core import signing
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.html import escape
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.utils.safestring import mark_safe
|
||||
from markdown import Extension
|
||||
@@ -52,6 +53,8 @@ from markdown.postprocessors import Postprocessor
|
||||
from markdown.treeprocessors import UnescapeTreeprocessor
|
||||
from tlds import tld_set
|
||||
|
||||
from pretix.helpers.format import SafeFormatter, format_map
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@@ -321,27 +324,44 @@ class LinkifyAndCleanExtension(Extension):
|
||||
)
|
||||
|
||||
|
||||
def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes=ALLOWED_ATTRIBUTES):
|
||||
def markdown_compile_email(source, allowed_tags=None, allowed_attributes=ALLOWED_ATTRIBUTES, snippet=False, context=None):
|
||||
if allowed_tags is None:
|
||||
allowed_tags = ALLOWED_TAGS_SNIPPET if snippet else ALLOWED_TAGS
|
||||
|
||||
context_callbacks = []
|
||||
if context:
|
||||
# This is a workaround to fix placeholders in URL targets
|
||||
def context_callback(attrs, new=False):
|
||||
if (None, "href") in attrs and "{" in attrs[None, "href"]:
|
||||
# Do not use MODE_RICH_TO_HTML to avoid recursive linkification
|
||||
attrs[None, "href"] = escape(format_map(attrs[None, "href"], context=context, mode=SafeFormatter.MODE_RICH_TO_PLAIN))
|
||||
return attrs
|
||||
|
||||
context_callbacks.append(context_callback)
|
||||
|
||||
linker = bleach.Linker(
|
||||
url_re=URL_RE,
|
||||
email_re=EMAIL_RE,
|
||||
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||
callbacks=context_callbacks + DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||
parse_email=True
|
||||
)
|
||||
exts = [
|
||||
'markdown.extensions.sane_lists',
|
||||
'markdown.extensions.tables',
|
||||
EmailNl2BrExtension(),
|
||||
LinkifyAndCleanExtension(
|
||||
linker,
|
||||
tags=set(allowed_tags),
|
||||
attributes=allowed_attributes,
|
||||
protocols=ALLOWED_PROTOCOLS,
|
||||
strip=snippet,
|
||||
)
|
||||
]
|
||||
if snippet:
|
||||
exts.append(SnippetExtension())
|
||||
return markdown.markdown(
|
||||
source,
|
||||
extensions=[
|
||||
'markdown.extensions.sane_lists',
|
||||
'markdown.extensions.tables',
|
||||
EmailNl2BrExtension(),
|
||||
LinkifyAndCleanExtension(
|
||||
linker,
|
||||
tags=set(allowed_tags),
|
||||
attributes=allowed_attributes,
|
||||
protocols=ALLOWED_PROTOCOLS,
|
||||
strip=False,
|
||||
)
|
||||
]
|
||||
extensions=exts
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ import pycountry
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.db.models import Prefetch, Q, prefetch_related_objects
|
||||
from django.forms import formset_factory, inlineformset_factory
|
||||
from django.urls import reverse
|
||||
@@ -67,8 +66,9 @@ from pretix.base.models.tax import TAX_CODE_LISTS
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||
from pretix.base.services.placeholders import FormPlaceholderMixin
|
||||
from pretix.base.settings import (
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES,
|
||||
PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, DEFAULTS,
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES,
|
||||
validate_event_settings,
|
||||
)
|
||||
from pretix.base.validators import multimail_validate
|
||||
from pretix.control.forms import (
|
||||
@@ -373,6 +373,13 @@ class EventUpdateForm(I18nModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.change_slug:
|
||||
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
|
||||
|
||||
if self.instance.orders.exists():
|
||||
self.fields['currency'].disabled = True
|
||||
self.fields['currency'].help_text = _(
|
||||
'The currency cannot be changed because orders already exist.'
|
||||
)
|
||||
|
||||
self.fields['location'].widget.attrs['rows'] = '3'
|
||||
self.fields['location'].widget.attrs['placeholder'] = _(
|
||||
'Sample Conference Center\nHeidelberg, Germany'
|
||||
@@ -541,7 +548,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
|
||||
'show_date_to',
|
||||
'show_times',
|
||||
'show_items_outside_presale_period',
|
||||
'display_net_prices',
|
||||
'hide_prices_from_attendees',
|
||||
'presale_start_show_date',
|
||||
'locales',
|
||||
@@ -799,6 +805,80 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
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):
|
||||
"""
|
||||
This is a SettingsForm, but if fields are set to required=True, validation
|
||||
@@ -872,6 +952,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
'invoice_address_from',
|
||||
'invoice_address_from_zipcode',
|
||||
'invoice_address_from_city',
|
||||
'invoice_address_from_state',
|
||||
'invoice_address_from_country',
|
||||
'invoice_address_from_tax_id',
|
||||
'invoice_address_from_vat_id',
|
||||
@@ -918,8 +999,6 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
self.fields['invoice_generate_sales_channels'].choices = (
|
||||
(c.identifier, c.label) for c in event.organizer.sales_channels.all()
|
||||
)
|
||||
self.fields['invoice_numbers_counter_length'].validators.append(MaxValueValidator(15))
|
||||
|
||||
pps = [str(pp.verbose_name) for pp in event.get_payment_providers().values() if pp.requires_invoice_immediately]
|
||||
if pps:
|
||||
generate_paid_help_text = _('An invoice will be issued before payment if the customer selects one of the following payment methods: {list}').format(
|
||||
@@ -944,6 +1023,26 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
(a, a) for a in get_fonts(event, pdf_support_required=True).keys()
|
||||
]
|
||||
|
||||
if 'invoice_address_from_country' in self.data:
|
||||
cc = str(self.data['invoice_address_from_country'])
|
||||
elif 'invoice_address_from_country' in self.initial:
|
||||
cc = str(self.initial['invoice_address_from_country'])
|
||||
else:
|
||||
cc = self.obj.settings.invoice_address_from_country
|
||||
c = [('', '---')]
|
||||
state_label = pgettext_lazy('address', 'State')
|
||||
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
|
||||
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
||||
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
|
||||
if cc in COUNTRY_STATE_LABEL:
|
||||
state_label = COUNTRY_STATE_LABEL[cc]
|
||||
elif 'invoice_address_from_state' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data['invoice_address_from_state']
|
||||
self.fields['invoice_address_from_state'].choices = c
|
||||
self.fields['invoice_address_from_state'].label = state_label
|
||||
|
||||
|
||||
def contains_web_channel_validate(val):
|
||||
if "web" not in val:
|
||||
@@ -1527,7 +1626,10 @@ class TaxRuleLineForm(I18nForm):
|
||||
rate = forms.DecimalField(
|
||||
label=_('Deviating tax rate'),
|
||||
max_digits=10, decimal_places=2,
|
||||
required=False
|
||||
required=False,
|
||||
widget=forms.NumberInput(attrs={
|
||||
'placeholder': _('Deviating tax rate'),
|
||||
})
|
||||
)
|
||||
invoice_text = I18nFormField(
|
||||
label=_('Text on invoice'),
|
||||
@@ -1762,7 +1864,11 @@ class QuickSetupForm(I18nForm):
|
||||
self.fields['payment_banktransfer_bank_details'].required = False
|
||||
for f in self.fields.values():
|
||||
if 'data-required-if' in f.widget.attrs:
|
||||
del f.widget.attrs['data-required-if']
|
||||
f.widget.attrs['data-required-if'] += ",#id_payment_banktransfer__enabled"
|
||||
|
||||
self.fields['payment_banktransfer_bank_details'].widget.attrs["data-required-if"] = (
|
||||
"#id_payment_banktransfer_bank_details_type_1,#id_payment_banktransfer__enabled"
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
@@ -47,6 +47,7 @@ from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.html import escape, format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as __, gettext_lazy as _
|
||||
from django_scopes.forms import (
|
||||
SafeModelChoiceField, SafeModelMultipleChoiceField,
|
||||
@@ -56,10 +57,14 @@ from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm
|
||||
from pretix.base.forms.widgets import DatePickerWidget
|
||||
from pretix.base.models import (
|
||||
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
|
||||
Item, ItemCategory, ItemProgramTime, ItemVariation, Order, OrderPosition,
|
||||
Question, QuestionOption, Quota,
|
||||
)
|
||||
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
|
||||
from pretix.base.signals import item_copy_data
|
||||
from pretix.base.subevent import (
|
||||
SubeventSelectionField, SubEventSelectionWrapper,
|
||||
)
|
||||
from pretix.control.forms import (
|
||||
ButtonGroupRadioSelect, ExtFileField, ItemMultipleChoiceField,
|
||||
SalesChannelCheckboxSelectMultiple, SplitDateTimeField,
|
||||
@@ -271,6 +276,87 @@ class QuestionOptionForm(I18nModelForm):
|
||||
]
|
||||
|
||||
|
||||
class QuestionFilterForm(forms.Form):
|
||||
STATUS_VARIANTS = [
|
||||
("", _("All orders")),
|
||||
("p", _("Paid")),
|
||||
("pv", _("Paid or confirmed")),
|
||||
("n", _("Pending")),
|
||||
("np", _("Pending or paid")),
|
||||
("o", _("Pending (overdue)")),
|
||||
("e", _("Expired")),
|
||||
("ne", _("Pending or expired")),
|
||||
("c", _("Canceled"))
|
||||
]
|
||||
|
||||
status = forms.ChoiceField(
|
||||
choices=STATUS_VARIANTS,
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
'class': 'form-control',
|
||||
}
|
||||
),
|
||||
required=False,
|
||||
label=_("Status"),
|
||||
initial="np",
|
||||
)
|
||||
item = forms.ChoiceField(
|
||||
choices=[],
|
||||
widget=forms.Select(
|
||||
attrs={'class': 'form-control'}
|
||||
),
|
||||
required=False,
|
||||
label=_("Items")
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent_selection'] = SubeventSelectionField(
|
||||
event=self.event,
|
||||
label=_("Subevents"),
|
||||
help_text=_("Select the subevents that should be included in the statistics either by subevent or by the timerange in which they occur."),
|
||||
)
|
||||
self.fields['item'].choices = [('', _('All products'))] + [(item.id, item.name) for item in
|
||||
Item.objects.filter(event=self.event)]
|
||||
|
||||
def filter_qs(self):
|
||||
fdata = self.cleaned_data
|
||||
|
||||
opqs = OrderPosition.objects.filter(
|
||||
order__event=self.event,
|
||||
)
|
||||
sub_event_qs = SubEventSelectionWrapper.from_string(fdata['subevent_selection']).get_queryset(self.event)
|
||||
opqs = opqs.filter(subevent__in=sub_event_qs)
|
||||
|
||||
s = fdata.get("status", "np")
|
||||
if s != "":
|
||||
if s == 'o':
|
||||
opqs = opqs.filter(order__status=Order.STATUS_PENDING,
|
||||
order__expires__lt=now().replace(hour=0, minute=0, second=0))
|
||||
elif s == 'np':
|
||||
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
|
||||
elif s == 'pv':
|
||||
opqs = opqs.filter(
|
||||
Q(order__status=Order.STATUS_PAID) |
|
||||
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
|
||||
)
|
||||
elif s == 'ne':
|
||||
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
|
||||
else:
|
||||
opqs = opqs.filter(order__status=s)
|
||||
|
||||
if s not in (Order.STATUS_CANCELED, ""):
|
||||
opqs = opqs.filter(canceled=False)
|
||||
if fdata.get("item", "") != "":
|
||||
i = fdata.get("item", "")
|
||||
opqs = opqs.filter(item_id__in=(i,))
|
||||
|
||||
return opqs
|
||||
|
||||
|
||||
class QuotaForm(I18nModelForm):
|
||||
itemvars = forms.MultipleChoiceField(
|
||||
label=_("Products"),
|
||||
@@ -572,6 +658,8 @@ class ItemCreateForm(I18nModelForm):
|
||||
for b in self.cleaned_data['copy_from'].bundles.all():
|
||||
instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation,
|
||||
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)
|
||||
|
||||
@@ -1321,3 +1409,49 @@ class ItemMetaValueForm(forms.ModelForm):
|
||||
widgets = {
|
||||
'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(),
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ class UserEditForm(forms.ModelForm):
|
||||
'email',
|
||||
'require_2fa',
|
||||
'is_active',
|
||||
'is_verified',
|
||||
'is_staff',
|
||||
'needs_password_change',
|
||||
'last_login'
|
||||
|
||||
@@ -308,8 +308,8 @@ class VoucherBulkForm(VoucherForm):
|
||||
)
|
||||
Recipient = namedtuple('Recipient', 'email number name tag')
|
||||
|
||||
def _set_field_placeholders(self, fn, base_parameters):
|
||||
placeholders = get_available_placeholders(self.instance.event, base_parameters)
|
||||
def _set_field_placeholders(self, fn, base_parameters, rich=False):
|
||||
placeholders = get_available_placeholders(self.instance.event, base_parameters, rich=rich)
|
||||
ht = format_placeholders_help_text(placeholders, self.instance.event)
|
||||
|
||||
if self.fields[fn].help_text:
|
||||
@@ -345,7 +345,7 @@ class VoucherBulkForm(VoucherForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._set_field_placeholders('send_subject', ['event', 'name'])
|
||||
self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name'])
|
||||
self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name'], rich=True)
|
||||
|
||||
with language(self.instance.event.settings.locale, self.instance.event.settings.region):
|
||||
for f in ("send_subject", "send_message"):
|
||||
|
||||
@@ -582,6 +582,7 @@ class CoreOrderLogEntryType(OrderLogEntryType):
|
||||
'The voucher has been set to expire because the recipient removed themselves from the waiting list.'),
|
||||
'pretix.voucher.changed': _('The voucher has been changed.'),
|
||||
'pretix.voucher.deleted': _('The voucher has been deleted.'),
|
||||
'pretix.voucher.carts.deleted': _('Cart positions including the voucher have been deleted.'),
|
||||
'pretix.voucher.added.waitinglist': _('The voucher has been assigned to {email} through the waiting list.'),
|
||||
})
|
||||
class CoreVoucherLogEntryType(VoucherLogEntryType):
|
||||
@@ -667,6 +668,14 @@ class UserSettingsChangedLogEntryType(LogEntryType):
|
||||
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):
|
||||
def display(self, logentry, data):
|
||||
return self.plain.format(data['other_email'])
|
||||
@@ -882,6 +891,9 @@ class EventPluginStateLogEntryType(EventLogEntryType):
|
||||
'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.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):
|
||||
pass
|
||||
|
||||
@@ -72,7 +72,7 @@ class PermissionMiddleware:
|
||||
)
|
||||
|
||||
EXCEPTIONS_FORCED_PW_CHANGE = (
|
||||
"user.settings",
|
||||
"user.settings.password.change",
|
||||
"auth.logout"
|
||||
)
|
||||
|
||||
@@ -139,7 +139,7 @@ class PermissionMiddleware:
|
||||
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
|
||||
except SessionPasswordChangeRequired:
|
||||
if url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
|
||||
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
|
||||
return redirect_to_url(reverse('control:user.settings.password.change') + '?next=' + quote(request.get_full_path()))
|
||||
except Session2FASetupRequired:
|
||||
if url_name not in self.EXCEPTIONS_2FA:
|
||||
return redirect_to_url(reverse('control:user.settings.2fa'))
|
||||
|
||||
@@ -86,7 +86,7 @@ def get_event_navigation(request: HttpRequest):
|
||||
'active': url.url_name == 'event.settings.mail',
|
||||
},
|
||||
{
|
||||
'label': _('Tax rules'),
|
||||
'label': _('Taxes'),
|
||||
'url': reverse('control:event.settings.tax', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<h3>{% trans "Set new password" %}</h3>
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form type='all' layout='inline' %}
|
||||
{% bootstrap_field form.email %}
|
||||
{% bootstrap_field form.password %}
|
||||
{% bootstrap_field form.password_repeat %}
|
||||
<div class="form-group buttons">
|
||||
|
||||
@@ -126,7 +126,9 @@
|
||||
{% endif %}
|
||||
<a class="navbar-brand" href="{% url "control:index" %}">
|
||||
<img src="{% static "pretixbase/img/pretix-icon-white-mini.svg" %}" />
|
||||
{{ settings.PRETIX_INSTANCE_NAME }}
|
||||
<span>
|
||||
{{ settings.PRETIX_INSTANCE_NAME }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="nav navbar-nav navbar-top-links navbar-left flip hidden-xs">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -49,13 +49,14 @@
|
||||
{% bootstrap_field form.invoice_address_custom_field_helptext layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_explanation_text layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<fieldset data-address-information-url="{% url "js_helpers.address_form" %}">
|
||||
<legend>{% trans "Issuer details" %}</legend>
|
||||
{% bootstrap_field form.invoice_address_from_name layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_zipcode layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_city layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_country layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_state layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_tax_id layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_vat_id layout="control" %}
|
||||
</fieldset>
|
||||
|
||||
@@ -243,7 +243,6 @@
|
||||
{% bootstrap_field sform.show_times layout="control" %}
|
||||
<h4>{% trans "Product list" %}</h4>
|
||||
{% 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.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 %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% load i18n %}
|
||||
<div class="subevent-selection col-lg-12">
|
||||
{% for group_name, group_choices, group-index in widget.subwidgets.0.optgroups %}
|
||||
{% for selopt in group_choices %}
|
||||
<div class="radio">
|
||||
<label class="col-lg-2">
|
||||
<input type="radio" name="{{ widget.subwidgets.0.name }}" value="{{ selopt.value }}"
|
||||
{% include "django/forms/widgets/attrs.html" with widget=selopt %} />
|
||||
{{ selopt.label }}
|
||||
</label>
|
||||
|
||||
{% if selopt.value == "subevent" %}
|
||||
|
||||
{% with widget.subwidgets.1 as widget %}
|
||||
{% include widget.template_name %}
|
||||
{% endwith %}
|
||||
|
||||
{% elif selopt.value == "timerange" %}
|
||||
|
||||
{% with widget.subwidgets.2 as widget %}
|
||||
{% include widget.template_name %}
|
||||
{% endwith %}
|
||||
|
||||
<span class="spacer">{% trans "until" %}</span>
|
||||
|
||||
{% with widget.subwidgets.3 as widget %}
|
||||
{% include widget.template_name %}
|
||||
{% endwith %}
|
||||
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -5,6 +5,11 @@
|
||||
{% load formset_tags %}
|
||||
{% block title %}{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}{% endblock %}
|
||||
{% block inside %}
|
||||
{% for e in form.errors.values %}
|
||||
<div class="alert alert-danger has-error">
|
||||
{{ e }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<h1>
|
||||
{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}
|
||||
<a href="{% url "control:event.items.questions.edit" event=request.event.slug organizer=request.event.organizer.slug question=question.pk %}"
|
||||
@@ -20,35 +25,24 @@
|
||||
</div>
|
||||
<form class="panel-body filter-form" action="" method="get">
|
||||
<div class="row">
|
||||
<div class="col-lg-2 col-sm-6 col-xs-6">
|
||||
<select name="status" class="form-control">
|
||||
<option value="" {% if request.GET.status == "" %}selected="selected"{% endif %}>{% trans "All orders" %}</option>
|
||||
<option value="p" {% if request.GET.status == "p" %}selected="selected"{% endif %}>{% trans "Paid" %}</option>
|
||||
<option value="pv" {% if request.GET.status == "pv" %}selected="selected"{% endif %}>{% trans "Paid or confirmed" %}</option>
|
||||
<option value="n" {% if request.GET.status == "n" %}selected="selected"{% endif %}>{% trans "Pending" %}</option>
|
||||
<option value="np" {% if request.GET.status == "np" or "status" not in request.GET %}selected="selected"{% endif %}>{% trans "Pending or paid" %}</option>
|
||||
<option value="o" {% if request.GET.status == "o" %}selected="selected"{% endif %}>{% trans "Pending (overdue)" %}</option>
|
||||
<option value="e" {% if request.GET.status == "e" %}selected="selected"{% endif %}>{% trans "Expired" %}</option>
|
||||
<option value="ne" {% if request.GET.status == "ne" %}selected="selected"{% endif %}>{% trans "Pending or expired" %}</option>
|
||||
<option value="c" {% if request.GET.status == "c" %}selected="selected"{% endif %}>{% trans "Canceled" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-lg-5 col-sm-6 col-xs-6">
|
||||
<select name="item" class="form-control">
|
||||
<option value="">{% trans "All products" %}</option>
|
||||
{% for item in items %}
|
||||
<option value="{{ item.id }}"
|
||||
{% if request.GET.item|add:0 == item.id %}selected="selected"{% endif %}>
|
||||
{{ item.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if request.event.has_subevents %}
|
||||
<div class="col-lg-5 col-sm-6 col-xs-6">
|
||||
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
|
||||
|
||||
<div class="col-lg-4 col-sm-6 col-xs-6">
|
||||
{% bootstrap_label form.status.label %}
|
||||
{% bootstrap_field form.status layout="inline" %}
|
||||
|
||||
</div>
|
||||
<div class="col-lg-8 col-sm-6 col-xs-6">
|
||||
{% bootstrap_label form.item.label %}
|
||||
{% bootstrap_field form.item layout="inline" %}
|
||||
</div>
|
||||
|
||||
<div class="col-lg-12 col-sm-6 col-xs-6">
|
||||
{% bootstrap_label form.subevent_selection.label %}
|
||||
{{ form.subevent_selection }}
|
||||
<div class="help-block">
|
||||
{{ form.subevent_selection.help_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<button class="btn btn-primary btn-lg" type="submit">
|
||||
|
||||
@@ -362,6 +362,11 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if staff_session %}
|
||||
<a class="btn btn-default btn-xs admin-only" href="{% url "control:event.order.inspect" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if forloop.revcounter0 > 0 %}
|
||||
<br/>
|
||||
{% endif %}
|
||||
@@ -681,6 +686,16 @@
|
||||
</small>
|
||||
{% 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 class="clearfix"></div>
|
||||
</div>
|
||||
@@ -721,6 +736,16 @@
|
||||
</small>
|
||||
{% 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 class="clearfix"></div>
|
||||
</div>
|
||||
@@ -749,6 +774,12 @@
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
|
||||
<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 class="clearfix"></div>
|
||||
</div>
|
||||
|
||||
@@ -56,8 +56,26 @@
|
||||
<td>{{ t.get_tax_code_display }}</td>
|
||||
<td class="text-right flip">{{ t.count }} ×</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">{{ t.full_price|money:request.event.currency }}</td>
|
||||
<td class="text-right flip">
|
||||
{{ 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>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -91,6 +91,8 @@
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-default" data-formset-add>
|
||||
<i class="fa fa-plus"></i> {% trans "Add a new value" %}</button>
|
||||
<button type="button" class="btn btn-default" data-formset-sort>
|
||||
<i class="fa fa-sort-alpha-asc"></i> {% trans "Sort alphabetically" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 %}
|
||||
{% block title %}{% trans "Account settings" %}{% endblock %}
|
||||
{% 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>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
<form action="" method="post" class="form-horizontal" data-testid="usersettingsform">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
@@ -13,7 +31,7 @@
|
||||
{% bootstrap_field form.locale layout='horizontal' %}
|
||||
{% bootstrap_field form.timezone layout='horizontal' %}
|
||||
<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">
|
||||
{% if request.user.notifications_send and request.user.notification_settings.exists %}
|
||||
<span class="label label-success">
|
||||
@@ -41,8 +59,18 @@
|
||||
{% bootstrap_field form.new_pw layout='horizontal' %}
|
||||
{% bootstrap_field form.new_pw_repeat layout='horizontal' %}
|
||||
{% 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">
|
||||
<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">
|
||||
{% if user.require_2fa %}
|
||||
<span class="label label-success">{% trans "Enabled" %}</span>
|
||||
@@ -58,7 +86,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<a href="{% url "control:user.settings.oauth.list" %}">
|
||||
<span class="fa fa-plug"></span>
|
||||
@@ -67,7 +95,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<a href="{% url "control:user.settings.history" %}">
|
||||
<span class="fa fa-history"></span>
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
{% if form.new_pw %}
|
||||
{% bootstrap_field form.new_pw layout='control' %}
|
||||
{% bootstrap_field form.new_pw_repeat layout='control' %}
|
||||
{% bootstrap_field form.is_verified layout='control' %}
|
||||
{% endif %}
|
||||
{% bootstrap_field form.last_login layout='control' %}
|
||||
{% bootstrap_field form.require_2fa layout='control' %}
|
||||
|
||||
@@ -110,6 +110,10 @@ urlpatterns = [
|
||||
name='user.settings.2fa.confirm.webauthn'),
|
||||
re_path(r'^settings/2fa/(?P<devicetype>[^/]+)/(?P<device>[0-9]+)/delete', user.User2FADeviceDeleteView.as_view(),
|
||||
name='user.settings.2fa.delete'),
|
||||
re_path(r'^settings/email/confirm$', user.UserEmailConfirmView.as_view(), name='user.settings.email.confirm'),
|
||||
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/add$', organizer.OrganizerCreate.as_view(), name='organizers.add'),
|
||||
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/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/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/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'),
|
||||
@@ -387,6 +391,8 @@ urlpatterns = [
|
||||
name='event.order.retransmitinvoice'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/invoices/(?P<id>\d+)/reissue$', orders.OrderInvoiceReissue.as_view(),
|
||||
name='event.order.reissueinvoice'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/invoices/(?P<id>\d+)/inspect$', orders.OrderInvoiceInspect.as_view(),
|
||||
name='event.order.inspect'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/download/(?P<position>\d+)/(?P<output>[^/]+)/$',
|
||||
orders.OrderDownload.as_view(),
|
||||
name='event.order.download.ticket'),
|
||||
|
||||
@@ -254,6 +254,9 @@ def invite(request, token):
|
||||
return redirect('control:index')
|
||||
else:
|
||||
with transaction.atomic():
|
||||
if request.user.email.lower() == inv.email.lower():
|
||||
request.user.is_verified = True
|
||||
request.user.save(update_fields=['is_verified'])
|
||||
inv.team.members.add(request.user)
|
||||
inv.team.log_action(
|
||||
'pretix.team.member.joined', data={
|
||||
@@ -274,7 +277,8 @@ def invite(request, token):
|
||||
user = User.objects.create_user(
|
||||
form.cleaned_data['email'], form.cleaned_data['password'],
|
||||
locale=request.LANGUAGE_CODE,
|
||||
timezone=request.timezone if hasattr(request, 'timezone') else settings.TIME_ZONE
|
||||
timezone=request.timezone if hasattr(request, 'timezone') else settings.TIME_ZONE,
|
||||
is_verified=form.cleaned_data['email'].lower() == inv.email.lower()
|
||||
)
|
||||
user = authenticate(request=request, email=user.email, password=form.cleaned_data['password'])
|
||||
user.log_action('pretix.control.auth.user.created', user=user)
|
||||
|
||||
@@ -54,7 +54,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.files import File
|
||||
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.http import (
|
||||
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed,
|
||||
@@ -90,7 +90,7 @@ from pretix.control.forms.event import (
|
||||
EventFooterLinkFormset, EventMetaValueForm, EventSettingsForm,
|
||||
EventUpdateForm, InvoiceSettingsForm, ItemMetaPropertyForm,
|
||||
MailSettingsForm, PaymentSettingsForm, ProviderForm, QuickSetupForm,
|
||||
QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet,
|
||||
QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet, TaxSettingsForm,
|
||||
TicketSettingsForm, WidgetCodeForm,
|
||||
)
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
@@ -648,6 +648,25 @@ class PaymentSettings(EventSettingsViewMixin, EventSettingsFormView):
|
||||
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):
|
||||
model = Event
|
||||
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):
|
||||
model = TaxRule
|
||||
form_class = TaxRuleForm
|
||||
|
||||
@@ -38,6 +38,7 @@ from collections import OrderedDict, namedtuple
|
||||
from itertools import groupby
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.files import File
|
||||
@@ -45,6 +46,7 @@ from django.db import transaction
|
||||
from django.db.models import (
|
||||
Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q,
|
||||
)
|
||||
from django.dispatch import receiver
|
||||
from django.forms.models import inlineformset_factory
|
||||
from django.http import (
|
||||
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
|
||||
@@ -60,24 +62,27 @@ from django.views.generic.detail import DetailView, SingleObjectMixin
|
||||
from django_countries.fields import Country
|
||||
|
||||
from pretix.api.serializers.item import (
|
||||
ItemAddOnSerializer, ItemBundleSerializer, ItemVariationSerializer,
|
||||
ItemAddOnSerializer, ItemBundleSerializer, ItemProgramTimeSerializer,
|
||||
ItemVariationSerializer,
|
||||
)
|
||||
from pretix.base.exporter import ListExporter
|
||||
from pretix.base.forms import I18nFormSet
|
||||
from pretix.base.models import (
|
||||
CartPosition, Item, ItemCategory, ItemVariation, Order, OrderPosition,
|
||||
Question, QuestionAnswer, QuestionOption, Quota, SeatCategoryMapping,
|
||||
Voucher,
|
||||
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation,
|
||||
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
|
||||
SeatCategoryMapping, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tickets import invalidate_cache
|
||||
from pretix.base.signals import quota_availability
|
||||
from pretix.base.signals import quota_availability, register_data_exporters
|
||||
from pretix.control.forms.item import (
|
||||
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
|
||||
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemUpdateForm,
|
||||
ItemVariationForm, ItemVariationsFormSet, QuestionForm, QuestionOptionForm,
|
||||
QuotaForm,
|
||||
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
|
||||
ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm,
|
||||
ItemVariationsFormSet, QuestionFilterForm, QuestionForm,
|
||||
QuestionOptionForm, QuotaForm,
|
||||
)
|
||||
from pretix.control.permissions import (
|
||||
EventPermissionRequiredMixin, event_permission_required,
|
||||
@@ -659,46 +664,73 @@ class QuestionMixin:
|
||||
return ctx
|
||||
|
||||
|
||||
class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingView, DetailView):
|
||||
class QuestionAnswerExporter(ListExporter):
|
||||
identifier = 'question_answer_exporter'
|
||||
verbose_name = _('Question answers exporter')
|
||||
description = _('Download a spreadsheet containing question answers')
|
||||
category = _('Order data')
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
form = {
|
||||
'question':
|
||||
forms.ModelChoiceField(
|
||||
label=_('Question'),
|
||||
queryset=Question.objects.filter(event=self.event),
|
||||
),
|
||||
**QuestionFilterForm(event=self.event).fields
|
||||
}
|
||||
|
||||
return form
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
question = Question.objects.filter(event=self.event).get(pk=form_data['question'])
|
||||
|
||||
opqs = QuestionFilterForm(event=self.event, data=form_data).order_position_queryset()
|
||||
|
||||
qs = QuestionAnswer.objects.filter(
|
||||
question=question, orderposition__isnull=False,
|
||||
)
|
||||
qs = qs.filter(orderposition__in=opqs)
|
||||
|
||||
headers = [
|
||||
_("Subevent"),
|
||||
_("Event start time"),
|
||||
_("Order"),
|
||||
_("Order position"),
|
||||
question.question
|
||||
]
|
||||
|
||||
yield headers
|
||||
yield self.ProgressSetTotal(total=qs.count())
|
||||
|
||||
for questionAnswer in qs.iterator(chunk_size=1000):
|
||||
row = [
|
||||
questionAnswer.orderposition.subevent.name,
|
||||
questionAnswer.orderposition.subevent.date_from.replace(tzinfo=None),
|
||||
questionAnswer.orderposition.order.code,
|
||||
questionAnswer.orderposition.positionid,
|
||||
questionAnswer.answer
|
||||
]
|
||||
|
||||
yield row
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_questions_exporter")
|
||||
def register_data_exporter(sender, **kwargs):
|
||||
return QuestionAnswerExporter
|
||||
|
||||
|
||||
class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView):
|
||||
model = Question
|
||||
template_name = 'pretixcontrol/items/question.html'
|
||||
permission = 'can_change_items'
|
||||
template_name_field = 'question'
|
||||
|
||||
def get_answer_statistics(self):
|
||||
opqs = OrderPosition.objects.filter(
|
||||
order__event=self.request.event,
|
||||
)
|
||||
def get_answer_statistics(self, opqs: OrderPosition):
|
||||
qs = QuestionAnswer.objects.filter(
|
||||
question=self.object, orderposition__isnull=False,
|
||||
)
|
||||
|
||||
if self.request.GET.get("subevent", "") != "":
|
||||
opqs = opqs.filter(subevent=self.request.GET["subevent"])
|
||||
|
||||
s = self.request.GET.get("status", "np")
|
||||
if s != "":
|
||||
if s == 'o':
|
||||
opqs = opqs.filter(order__status=Order.STATUS_PENDING,
|
||||
order__expires__lt=now().replace(hour=0, minute=0, second=0))
|
||||
elif s == 'np':
|
||||
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
|
||||
elif s == 'pv':
|
||||
opqs = opqs.filter(
|
||||
Q(order__status=Order.STATUS_PAID) |
|
||||
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
|
||||
)
|
||||
elif s == 'ne':
|
||||
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
|
||||
else:
|
||||
opqs = opqs.filter(order__status=s)
|
||||
|
||||
if s not in (Order.STATUS_CANCELED, ""):
|
||||
opqs = opqs.filter(canceled=False)
|
||||
if self.request.GET.get("item", "") != "":
|
||||
i = self.request.GET.get("item", "")
|
||||
opqs = opqs.filter(item_id__in=(i,))
|
||||
|
||||
qs = qs.filter(orderposition__in=opqs)
|
||||
op_cnt = opqs.filter(item__in=self.object.items.all()).count()
|
||||
|
||||
@@ -746,8 +778,20 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data()
|
||||
ctx['items'] = self.object.items.all()
|
||||
stats = self.get_answer_statistics()
|
||||
ctx['stats'], ctx['total'] = stats
|
||||
if self.request.GET:
|
||||
ctx['form'] = QuestionFilterForm(
|
||||
data=self.request.GET,
|
||||
event=self.request.event,
|
||||
)
|
||||
else:
|
||||
ctx['form'] = QuestionFilterForm(
|
||||
event=self.request.event,
|
||||
|
||||
)
|
||||
if ctx['form'].is_valid():
|
||||
opqs = ctx['form'].filter_qs()
|
||||
stats = self.get_answer_statistics(opqs)
|
||||
ctx['stats'], ctx['total'] = stats
|
||||
return ctx
|
||||
|
||||
def get_object(self, queryset=None) -> Question:
|
||||
@@ -1431,7 +1475,8 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
|
||||
form.instance.position = i
|
||||
setattr(form.instance, attr, self.get_object())
|
||||
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'):
|
||||
change_data = {k: form.cleaned_data.get(k) for k in form.changed_data}
|
||||
if key == 'variations':
|
||||
@@ -1497,6 +1542,16 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
|
||||
'bundles', 'bundles', 'base_item', order=False,
|
||||
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:
|
||||
v.save()
|
||||
|
||||
@@ -1559,9 +1614,20 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
|
||||
queryset=ItemBundle.objects.filter(base_item=self.get_object()),
|
||||
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:
|
||||
del f['variations']
|
||||
if self.item.event.has_subevents:
|
||||
del f['program_times']
|
||||
|
||||
i = 0
|
||||
for rec, resp in item_formsets.send(sender=self.request.event, item=self.item, request=self.request):
|
||||
|
||||
@@ -131,13 +131,16 @@ from pretix.control.forms.orders import (
|
||||
ReactivateOrderForm,
|
||||
)
|
||||
from pretix.control.forms.rrule import RRuleForm
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from pretix.control.permissions import (
|
||||
AdministratorPermissionRequiredMixin, EventPermissionRequiredMixin,
|
||||
)
|
||||
from pretix.control.signals import order_search_forms
|
||||
from pretix.control.views import PaginationMixin
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.compat import CompatDeleteView
|
||||
from pretix.helpers.format import SafeFormatter, format_map
|
||||
from pretix.helpers.hierarkey import clean_filename
|
||||
from pretix.helpers.json import CustomJSONEncoder
|
||||
from pretix.helpers.safedownload import check_token
|
||||
from pretix.presale.signals import question_form_fields
|
||||
|
||||
@@ -633,7 +636,9 @@ class OrderTransactions(OrderView):
|
||||
ctx['sums'] = self.order.transactions.aggregate(
|
||||
sum_count=Sum('count'),
|
||||
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_includes_rounding_correction=Sum(F('count') * F('tax_value_includes_rounding_correction')),
|
||||
)
|
||||
return ctx
|
||||
|
||||
@@ -1750,6 +1755,25 @@ class OrderInvoiceReissue(OrderView):
|
||||
return HttpResponseNotAllowed(['POST'])
|
||||
|
||||
|
||||
class OrderInvoiceInspect(AdministratorPermissionRequiredMixin, OrderView):
|
||||
|
||||
def get(self, *args, **kwargs): # NOQA
|
||||
inv = get_object_or_404(self.order.invoices, pk=kwargs.get('id'))
|
||||
d = {"lines": []}
|
||||
for f in inv._meta.fields:
|
||||
v = getattr(inv, f.name)
|
||||
d[f.name] = v
|
||||
|
||||
for il in inv.lines.all():
|
||||
line = {}
|
||||
for f in il._meta.fields:
|
||||
v = getattr(il, f.name)
|
||||
line[f.name] = v
|
||||
d["lines"].append(line)
|
||||
|
||||
return JsonResponse(d, encoder=CustomJSONEncoder)
|
||||
|
||||
|
||||
class OrderResendLink(OrderView):
|
||||
permission = 'can_change_orders'
|
||||
|
||||
@@ -2144,7 +2168,8 @@ class OrderChange(OrderView):
|
||||
self.order,
|
||||
user=self.request.user,
|
||||
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
|
||||
self._process_add_positions(ocm) and
|
||||
|
||||
@@ -44,7 +44,9 @@ from pypdf.errors import PdfReadError
|
||||
from reportlab.lib.units import mm
|
||||
|
||||
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.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
@@ -95,6 +97,9 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
|
||||
description=_("Sample product description"))
|
||||
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
|
||||
order = self.request.event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
|
||||
email='sample@pretix.eu',
|
||||
|
||||
@@ -820,12 +820,13 @@ def organizer_select2(request):
|
||||
total = qs.count()
|
||||
pagesize = 20
|
||||
offset = (page - 1) * pagesize
|
||||
display_slug = 'display_slug' in request.GET
|
||||
|
||||
doc = {
|
||||
"results": [
|
||||
{
|
||||
'id': o.pk,
|
||||
'text': str(o.name)
|
||||
'text': '{} — {}'.format(o.slug, o.name) if display_slug else str(o.name)
|
||||
} for o in qs[offset:offset + pagesize]
|
||||
],
|
||||
"pagination": {
|
||||
|
||||
@@ -44,11 +44,13 @@ from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import BadRequest, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.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.timezone import now
|
||||
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 pretix.base.auth import get_auth_backends
|
||||
from pretix.base.forms.auth import ReauthForm
|
||||
from pretix.base.forms.user import User2FADeviceAddForm, UserSettingsForm
|
||||
from pretix.base.forms.auth import ConfirmationCodeForm, ReauthForm
|
||||
from pretix.base.forms.user import (
|
||||
User2FADeviceAddForm, UserEmailChangeForm, UserPasswordChangeForm,
|
||||
UserSettingsForm,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Event, LogEntry, NotificationSetting, U2FDevice, User, WebAuthnDevice,
|
||||
)
|
||||
@@ -237,25 +242,7 @@ class UserSettings(UpdateView):
|
||||
|
||||
data = {}
|
||||
for k in form.changed_data:
|
||||
if k not in ('old_pw', 'new_pw_repeat'):
|
||||
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)
|
||||
data[k] = form.cleaned_data[k]
|
||||
|
||||
sup = super().form_valid(form)
|
||||
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'])
|
||||
else:
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django.conf import settings
|
||||
|
||||
THRESHOLD_DOWNGRADE_TO_MID = 50
|
||||
THRESHOLD_DOWNGRADE_TO_LOW = 250
|
||||
|
||||
|
||||
def get_task_priority(shard, organizer_id):
|
||||
"""
|
||||
This is an attempt to build a simple "fair-use" policy for webhooks and notifications. The problem is that when
|
||||
one organizer creates e.g. 20,000 orders through the API, that might schedule 20,000 webhooks and every other
|
||||
organizer will need to wait for these webhooks to go through.
|
||||
|
||||
We try to fix that by building three queues: high-prio, mid-prio, and low-prio. Every organizer starts in the
|
||||
high-prio queue, and all their tasks are routed immediately. Once an organizer submits more than X jobs of a
|
||||
certain type per minute, they get downgraded to the mid-prio queue, and then – if they submit even more – to the
|
||||
low-prio queue. That way, if another organizer has "regular usage", they are prioritized over the organizer with
|
||||
high load.
|
||||
"""
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
if not settings.HAS_REDIS:
|
||||
return settings.PRIORITY_CELERY_HIGH
|
||||
|
||||
# We use redis directly instead of the Django cache API since the Django cache API does not support INCR for
|
||||
# nonexistant keys
|
||||
rc = get_redis_connection("redis")
|
||||
|
||||
cache_key = f"pretix:task_priority:{shard}:{organizer_id}"
|
||||
|
||||
# Make sure counters expire after a while when not used
|
||||
p = rc.pipeline()
|
||||
p.incr(cache_key)
|
||||
p.expire(cache_key, 60)
|
||||
new_counter = p.execute()[0]
|
||||
|
||||
if new_counter >= THRESHOLD_DOWNGRADE_TO_LOW:
|
||||
return settings.PRIORITY_CELERY_LOW
|
||||
elif new_counter >= THRESHOLD_DOWNGRADE_TO_MID:
|
||||
return settings.PRIORITY_CELERY_MID
|
||||
else:
|
||||
return settings.PRIORITY_CELERY_HIGH
|
||||
@@ -22,6 +22,8 @@
|
||||
import logging
|
||||
from string import Formatter
|
||||
|
||||
from django.utils.html import conditional_escape
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -40,14 +42,14 @@ class SafeFormatter(Formatter):
|
||||
Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and
|
||||
(b) does not allow any unwanted shenanigans like attribute access or format specifiers.
|
||||
"""
|
||||
MODE_IGNORE_RICH = 0
|
||||
MODE_RICH_TO_PLAIN = 1
|
||||
MODE_RICH_TO_HTML = 2
|
||||
|
||||
def __init__(self, context, raise_on_missing=False, mode=MODE_IGNORE_RICH):
|
||||
def __init__(self, context, raise_on_missing=False, mode=MODE_RICH_TO_PLAIN, linkifier=None):
|
||||
self.context = context
|
||||
self.raise_on_missing = raise_on_missing
|
||||
self.mode = mode
|
||||
self.linkifier = linkifier
|
||||
|
||||
def get_field(self, field_name, args, kwargs):
|
||||
return self.get_value(field_name, args, kwargs), field_name
|
||||
@@ -55,22 +57,28 @@ class SafeFormatter(Formatter):
|
||||
def get_value(self, key, args, kwargs):
|
||||
if not self.raise_on_missing and key not in self.context:
|
||||
return '{' + str(key) + '}'
|
||||
r = self.context[key]
|
||||
if isinstance(r, PlainHtmlAlternativeString):
|
||||
if self.mode == self.MODE_IGNORE_RICH:
|
||||
return '{' + str(key) + '}'
|
||||
elif self.mode == self.MODE_RICH_TO_PLAIN:
|
||||
return r.plain
|
||||
return self.context[key]
|
||||
|
||||
def _prepare_value(self, value):
|
||||
if isinstance(value, PlainHtmlAlternativeString):
|
||||
if self.mode == self.MODE_RICH_TO_PLAIN:
|
||||
return value.plain
|
||||
elif self.mode == self.MODE_RICH_TO_HTML:
|
||||
return r.html
|
||||
return r
|
||||
return value.html
|
||||
else:
|
||||
value = str(value)
|
||||
if self.mode == self.MODE_RICH_TO_HTML:
|
||||
value = conditional_escape(value)
|
||||
if self.linkifier:
|
||||
value = self.linkifier.linkify(value)
|
||||
return value
|
||||
|
||||
def format_field(self, value, format_spec):
|
||||
# Ignore format_spec
|
||||
return super().format_field(value, '')
|
||||
return super().format_field(self._prepare_value(value), '')
|
||||
|
||||
|
||||
def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_IGNORE_RICH):
|
||||
def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN, linkifier=None):
|
||||
if not isinstance(template, str):
|
||||
template = str(template)
|
||||
return SafeFormatter(context, raise_on_missing, mode=mode).format(template)
|
||||
return SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template)
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django.core.files import File
|
||||
from django_countries.fields import Country
|
||||
from i18nfield.utils import I18nJSONEncoder
|
||||
from phonenumber_field.phonenumber import PhoneNumber
|
||||
|
||||
@@ -36,7 +37,9 @@ class CustomJSONEncoder(I18nJSONEncoder):
|
||||
return obj.name
|
||||
elif isinstance(obj, LazyI18nStringList):
|
||||
return [s.data for s in obj.data]
|
||||
if isinstance(obj, PhoneNumber):
|
||||
elif isinstance(obj, PhoneNumber):
|
||||
return str(obj)
|
||||
elif isinstance(obj, Country):
|
||||
return str(obj)
|
||||
else:
|
||||
return super().default(obj)
|
||||
|
||||
+2612
-2309
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-29 07:55+0000\n"
|
||||
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -344,8 +344,8 @@ msgstr ""
|
||||
msgid "close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/addressform.js:90
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:525
|
||||
#: pretix/static/pretixbase/js/addressform.js:98
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:529
|
||||
msgid "required"
|
||||
msgstr ""
|
||||
|
||||
@@ -622,56 +622,56 @@ msgstr ""
|
||||
msgid "Unknown error."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:292
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:309
|
||||
msgid "Your color has great contrast and will provide excellent accessibility."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:296
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:313
|
||||
msgid ""
|
||||
"Your color has decent contrast and is sufficient for minimum accessibility "
|
||||
"requirements."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:300
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:317
|
||||
msgid ""
|
||||
"Your color has insufficient contrast to white. Accessibility of your site "
|
||||
"will be impacted."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:418
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:438
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:443
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:463
|
||||
msgid "Search query"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:461
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:437
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:462
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:441
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:466
|
||||
msgid "Selected only"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:814
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:839
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:817
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:842
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:975
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1000
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1015
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1040
|
||||
msgid "Click to close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1096
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1121
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr ""
|
||||
|
||||
@@ -748,7 +748,7 @@ msgstr ""
|
||||
msgid "Please enter the amount the organizer can keep."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:570
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:577
|
||||
msgid "Your local time:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+2732
-2319
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-29 07:55+0000\n"
|
||||
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
|
||||
"PO-Revision-Date: 2021-09-15 11:22+0000\n"
|
||||
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
|
||||
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -359,8 +359,8 @@ msgstr "لا"
|
||||
msgid "close"
|
||||
msgstr "إغلاق"
|
||||
|
||||
#: pretix/static/pretixbase/js/addressform.js:90
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:525
|
||||
#: pretix/static/pretixbase/js/addressform.js:98
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:529
|
||||
msgid "required"
|
||||
msgstr "مطلوب"
|
||||
|
||||
@@ -656,13 +656,13 @@ msgstr "توليد الرسائل …"
|
||||
msgid "Unknown error."
|
||||
msgstr "خطأ غير معروف."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:292
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:309
|
||||
#, fuzzy
|
||||
#| msgid "Your color has great contrast and is very easy to read!"
|
||||
msgid "Your color has great contrast and will provide excellent accessibility."
|
||||
msgstr "اللون يتمتع بتباين كبير وتسهل قراءته!"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:296
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:313
|
||||
#, fuzzy
|
||||
#| msgid "Your color has decent contrast and is probably good-enough to read!"
|
||||
msgid ""
|
||||
@@ -670,46 +670,46 @@ msgid ""
|
||||
"requirements."
|
||||
msgstr "اللون يحظى بتباين معقول ويمكن أن يكون مناسب للقراءة!"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:300
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:317
|
||||
msgid ""
|
||||
"Your color has insufficient contrast to white. Accessibility of your site "
|
||||
"will be impacted."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:418
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:438
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:443
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:463
|
||||
msgid "Search query"
|
||||
msgstr "البحث في الاستفسارات"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:461
|
||||
msgid "All"
|
||||
msgstr "الكل"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:437
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:462
|
||||
msgid "None"
|
||||
msgstr "لا شيء"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:441
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:466
|
||||
msgid "Selected only"
|
||||
msgstr "المختارة فقط"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:814
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:839
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:817
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:842
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:975
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1000
|
||||
msgid "Use a different name internally"
|
||||
msgstr "قم باستخدم اسم مختلف داخليا"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1015
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1040
|
||||
msgid "Click to close"
|
||||
msgstr "اضغط لاغلاق الصفحة"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1096
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1121
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr "لم تقم بحفظ التعديلات!"
|
||||
|
||||
@@ -806,7 +806,7 @@ msgstr "ستسترد %(currency)%(amount)"
|
||||
msgid "Please enter the amount the organizer can keep."
|
||||
msgstr "الرجاء إدخال المبلغ الذي يمكن للمنظم الاحتفاظ به."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:570
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:577
|
||||
msgid "Your local time:"
|
||||
msgstr "التوقيت المحلي:"
|
||||
|
||||
|
||||
+2612
-2309
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-29 07:55+0000\n"
|
||||
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -344,8 +344,8 @@ msgstr ""
|
||||
msgid "close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/addressform.js:90
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:525
|
||||
#: pretix/static/pretixbase/js/addressform.js:98
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:529
|
||||
msgid "required"
|
||||
msgstr ""
|
||||
|
||||
@@ -622,56 +622,56 @@ msgstr ""
|
||||
msgid "Unknown error."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:292
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:309
|
||||
msgid "Your color has great contrast and will provide excellent accessibility."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:296
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:313
|
||||
msgid ""
|
||||
"Your color has decent contrast and is sufficient for minimum accessibility "
|
||||
"requirements."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:300
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:317
|
||||
msgid ""
|
||||
"Your color has insufficient contrast to white. Accessibility of your site "
|
||||
"will be impacted."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:418
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:438
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:443
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:463
|
||||
msgid "Search query"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:461
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:437
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:462
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:441
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:466
|
||||
msgid "Selected only"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:814
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:839
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:817
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:842
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:975
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1000
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1015
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1040
|
||||
msgid "Click to close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1096
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1121
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr ""
|
||||
|
||||
@@ -748,7 +748,7 @@ msgstr ""
|
||||
msgid "Please enter the amount the organizer can keep."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:570
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:577
|
||||
msgid "Your local time:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
+2727
-2326
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
Reference in New Issue
Block a user