mirror of
https://github.com/pretix/pretix.git
synced 2025-12-10 01:12:28 +00:00
Compare commits
90 Commits
fix-widget
...
invoice-ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7330f58dde | ||
|
|
b4264c0ae7 | ||
|
|
61eff28978 | ||
|
|
4e89772c2d | ||
|
|
3212dd9b40 | ||
|
|
97c1fb9101 | ||
|
|
d5bccf8726 | ||
|
|
d768c46fa1 | ||
|
|
5a506bfbd6 | ||
|
|
3508d22591 | ||
|
|
4a6dd12884 | ||
|
|
60b906d8b7 | ||
|
|
4285612162 | ||
|
|
a3b1e4d208 | ||
|
|
3a6d7b8e92 | ||
|
|
a5d01aa2d1 | ||
|
|
89d8ca0fc2 | ||
|
|
34b656989f | ||
|
|
154f10af8f | ||
|
|
782d659c59 | ||
|
|
1b4308e101 | ||
|
|
9a119c35a8 | ||
|
|
a8ac1b1a94 | ||
|
|
6338dceb9e | ||
|
|
e4a171c11f | ||
|
|
e9edcfdfdc | ||
|
|
ef3ff52be3 | ||
|
|
a8f74d87ec | ||
|
|
6f920e6bcd | ||
|
|
a6201c841f | ||
|
|
b5ac28e36c | ||
|
|
bf5e1aeaff | ||
|
|
3f6d230c01 | ||
|
|
a4aa3cbd3b | ||
|
|
8ee90cd1c4 | ||
|
|
8d1e679a84 | ||
|
|
87f829f4d2 | ||
|
|
75dcb920a7 | ||
|
|
e68f0a7402 | ||
|
|
4255dbfb83 | ||
|
|
9def5cc7b2 | ||
|
|
17a467887c | ||
|
|
0736babf3c | ||
|
|
a5b773924c | ||
|
|
391918afe7 | ||
|
|
d8f9f9478d | ||
|
|
4d9f1a8efc | ||
|
|
23b07e29cd | ||
|
|
e1756a1ebb | ||
|
|
f5b0454e9f | ||
|
|
724a109c52 | ||
|
|
96df3d6831 | ||
|
|
dc164f7817 | ||
|
|
61ff0a767a | ||
|
|
423f0cbb90 | ||
|
|
200d520535 | ||
|
|
e2ae553c69 | ||
|
|
3ddf759a1b | ||
|
|
614a086227 | ||
|
|
35583f30bb | ||
|
|
38be6d13da | ||
|
|
6a8ec1ec7f | ||
|
|
0b799b132d | ||
|
|
0dd66f9468 | ||
|
|
149f1ee871 | ||
|
|
ec60ea9603 | ||
|
|
04e92e9f2f | ||
|
|
14d6013292 | ||
|
|
415bff5c72 | ||
|
|
582c6c1771 | ||
|
|
13833b05b1 | ||
|
|
a381adac33 | ||
|
|
177b9cdcbb | ||
|
|
a5f7f2bd0c | ||
|
|
6bc88b3c0d | ||
|
|
d7759f7eab | ||
|
|
1aeaa39882 | ||
|
|
1e62d06f2d | ||
|
|
a90b40035c | ||
|
|
1c79e06af8 | ||
|
|
fda8c8bc37 | ||
|
|
3f11f351b8 | ||
|
|
43cc4333a6 | ||
|
|
e1821f1bb7 | ||
|
|
4514701d1b | ||
|
|
08baf0ee32 | ||
|
|
08bbdbbd97 | ||
|
|
25cd84c459 | ||
|
|
7177ac18f7 | ||
|
|
2788ba10fe |
@@ -8,6 +8,7 @@ pretix
|
||||
:target: https://docs.pretix.eu/
|
||||
|
||||
.. image:: https://github.com/pretix/pretix/workflows/Tests/badge.svg
|
||||
:target: https://github.com/pretix/pretix/actions/workflows/tests.yml
|
||||
|
||||
.. image:: https://codecov.io/gh/pretix/pretix/branch/master/graph/badge.svg
|
||||
:target: https://codecov.io/gh/pretix/pretix
|
||||
|
||||
@@ -349,6 +349,45 @@ Endpoints
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer or event or exhibitor does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/vouchers/bulk_attach/
|
||||
|
||||
Attaches many **existing** vouchers to an exhibitor. You need to send either the ``id`` **or** the ``code`` field of
|
||||
the voucher, but you need to send the same field for all entries.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/vouchers/bulk_attach/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
[
|
||||
{
|
||||
"id": 15,
|
||||
"exhibitor_comment": "Free ticket"
|
||||
},
|
||||
..
|
||||
]
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{}
|
||||
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the event to use
|
||||
:param id: The ``id`` field of the exhibitor to use
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: Invalid data sent, e.g. voucher does not exist
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer or event or exhibitor does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/
|
||||
|
||||
Create a new exhibitor.
|
||||
|
||||
@@ -25,6 +25,7 @@ at :ref:`plugin-docs`.
|
||||
seats
|
||||
orders
|
||||
invoices
|
||||
transactions
|
||||
vouchers
|
||||
discounts
|
||||
checkin
|
||||
@@ -54,6 +55,7 @@ at :ref:`plugin-docs`.
|
||||
digital
|
||||
exhibitors
|
||||
imported_secrets
|
||||
offlinesales
|
||||
shipping
|
||||
billing_invoices
|
||||
billing_var
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.. _rest-invoices:
|
||||
|
||||
Invoices
|
||||
========
|
||||
|
||||
|
||||
219
doc/api/resources/offlinesales.rst
Normal file
219
doc/api/resources/offlinesales.rst
Normal file
@@ -0,0 +1,219 @@
|
||||
Offline sales
|
||||
=============
|
||||
|
||||
.. note:: This API is only available when the plugin **pretix-offlinesales** is installed (pretix Hosted and Enterprise only).
|
||||
|
||||
The offline sales module allows you to create batches of tickets intended for the sale outside the system.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The offline sales batch resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal batch ID
|
||||
creation datetime Time of creation
|
||||
testmode boolean ``true`` if orders are created in test mode
|
||||
sales_channel string Sales channel of the orders
|
||||
layout integer Internal ID of the chosen ticket layout
|
||||
subevent integer Internal ID of the chosen subevent (or ``null``)
|
||||
item integer Internal ID of the chosen product
|
||||
variation integer Internal ID of the chosen variation (or ``null``)
|
||||
amount integer Number of tickets in the batch
|
||||
comment string Internal comment
|
||||
orders list of strings List of order codes (omitted in list view for performance reasons)
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/offlinesalesbatches/
|
||||
|
||||
Returns a list of all offline sales batches
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/democon/offlinesalesbatches/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"creation": "2025-07-08T18:27:32.134368+02:00",
|
||||
"testmode": False,
|
||||
"sales_channel": "web",
|
||||
"comment": "Batch for sale at the event",
|
||||
"layout": 3,
|
||||
"subevent": null,
|
||||
"item": 23,
|
||||
"variation": null,
|
||||
"amount": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of a valid event
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/offlinesalesbatches/(id)/
|
||||
|
||||
Returns information on a given batch.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/democon/offlinesalesbatches/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: text/javascript
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"creation": "2025-07-08T18:27:32.134368+02:00",
|
||||
"testmode": False,
|
||||
"sales_channel": "web",
|
||||
"comment": "Batch for sale at the event",
|
||||
"layout": 3,
|
||||
"subevent": null,
|
||||
"item": 23,
|
||||
"variation": null,
|
||||
"amount": 7,
|
||||
"orders": ["TSRNN", "3FBSL", "WMDNJ", "BHW9H", "MXSUG", "DSDAP", "URLLE"]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the batch 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 it.
|
||||
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/offlinesalesbatches/
|
||||
|
||||
With this API call, you can instruct the system to create a new batch.
|
||||
|
||||
Since batches can contain up to 10,000 tickets, they are created asynchronously on the server.
|
||||
If your input parameters validate correctly, a ``202 Accepted`` status code is returned.
|
||||
The body points you to the check URL of the result. Running a ``GET`` request on that result URL will
|
||||
yield one of the following status codes:
|
||||
|
||||
* ``200 OK`` – The creation of the batch has succeeded. The body will be your resulting batch with the same information as in the detail endpoint above.
|
||||
* ``409 Conflict`` – Your creation job is still running. The body will be JSON with the structure ``{"status": "running"}``. ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do.
|
||||
* ``410 Gone`` – Creating the batch has failed permanently (e.g. quota no longer available). The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}``
|
||||
* ``404 Not Found`` – The job does not exist / is expired.
|
||||
|
||||
.. note:: To avoid performance issues, a maximum amount of 10000 is currently allowed.
|
||||
|
||||
.. note:: Do not wait multiple hours or more to retrieve your result. After a longer wait time, ``409`` might be returned permanently due to technical constraints, even though nothing will happen any more.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/offlinesalesbatches/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"testmode": True,
|
||||
"layout": 123,
|
||||
"item": 14,
|
||||
"sales_channel": "web",
|
||||
"amount": 10,
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"check": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/offlinesalesbatches/check/29891ede-196f-4942-9e26-d055a36e98b8/"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 202: no error
|
||||
:statuscode 400: Invalid input options
|
||||
: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)/offlinesalesbatches/(id)/render/
|
||||
|
||||
With this API call, you can render the PDF representation of a batch.
|
||||
|
||||
Since batches can contain up to 10,000 tickets, they are rendered asynchronously on the server.
|
||||
If your input parameters validate correctly, a ``202 Accepted`` status code is returned.
|
||||
The body points you to the download URL of the result. Running a ``GET`` request on that result URL will
|
||||
yield one of the following status codes:
|
||||
|
||||
* ``200 OK`` – The creation of the batch has succeeded. The body will be your resulting batch with the same information as in the detail endpoint above.
|
||||
* ``409 Conflict`` – Your rendering process is still running. The body will be JSON with the structure ``{"status": "running"}``. ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do.
|
||||
* ``410 Gone`` – Rendering the batch has failed permanently. The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}``
|
||||
* ``404 Not Found`` – The rendering job does not exist / is expired.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/offlinesalesbatches/1/render 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
|
||||
|
||||
{
|
||||
"download": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/offlinesalesbatches/1/download/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the batch to fetch
|
||||
:statuscode 202: no error
|
||||
:statuscode 400: Invalid input options
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
@@ -28,6 +28,8 @@ closed boolean Whether the quo
|
||||
field).
|
||||
release_after_exit boolean Whether the quota regains capacity as soon as some tickets
|
||||
have been scanned at an exit.
|
||||
ignore_for_event_availability boolean Whether the quota is ignored when calculating the event's
|
||||
availability of tickets.
|
||||
available boolean Whether this quota is available. Only returned if ``with_availability=true``
|
||||
is set on the request. Do not rely on this value for critical operations, it may be
|
||||
slightly out of date.
|
||||
@@ -72,7 +74,8 @@ Endpoints
|
||||
"variations": [1, 4, 5, 7],
|
||||
"subevent": null,
|
||||
"close_when_sold_out": false,
|
||||
"closed": false
|
||||
"closed": false,
|
||||
"ignore_for_event_availability": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -118,7 +121,8 @@ Endpoints
|
||||
"variations": [1, 4, 5, 7],
|
||||
"subevent": null,
|
||||
"close_when_sold_out": false,
|
||||
"closed": false
|
||||
"closed": false,
|
||||
"ignore_for_event_availability": false
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -149,7 +153,8 @@ Endpoints
|
||||
"variations": [1, 4, 5, 7],
|
||||
"subevent": null,
|
||||
"close_when_sold_out": false,
|
||||
"closed": false
|
||||
"closed": false,
|
||||
"ignore_for_event_availability": false
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -168,7 +173,8 @@ Endpoints
|
||||
"variations": [1, 4, 5, 7],
|
||||
"subevent": null,
|
||||
"close_when_sold_out": false,
|
||||
"closed": false
|
||||
"closed": false,
|
||||
"ignore_for_event_availability": false
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event/item to create a quota for
|
||||
@@ -223,7 +229,8 @@ Endpoints
|
||||
],
|
||||
"subevent": null,
|
||||
"close_when_sold_out": false,
|
||||
"closed": false
|
||||
"closed": false,
|
||||
"ignore_for_event_availability": false
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
|
||||
232
doc/api/resources/transactions.rst
Normal file
232
doc/api/resources/transactions.rst
Normal file
@@ -0,0 +1,232 @@
|
||||
.. _rest-transactions:
|
||||
|
||||
Transactions
|
||||
============
|
||||
|
||||
Transactions are an additional way to think about orders. They are are an immutable, filterable view into an order's
|
||||
history and are a good basis for financial reporting.
|
||||
|
||||
Our financial model
|
||||
-------------------
|
||||
|
||||
You can think of a pretix order similar to a debtor account in double-entry bookkeeping. For example, the flow of an
|
||||
order could look like this:
|
||||
|
||||
===================================================== ==================== =====================
|
||||
Transaction Debit Credit
|
||||
===================================================== ==================== =====================
|
||||
Order is placed with two tickets € 500
|
||||
Order is paid partially with a gift card € 200
|
||||
Remainder is paid with a credit card € 300
|
||||
One of the tickets is canceled **-** € 250
|
||||
Refund is made to the credit card **-** € 250
|
||||
**Balance** **€ 250** **€ 250**
|
||||
===================================================== ==================== =====================
|
||||
|
||||
If an order is fully settled, the sums of both columns match. However, as the movements in both columns do not always
|
||||
happen at the same time, at some times during the lifecycle of an order the sums are not balanced, in which case we
|
||||
consider an order to be "pending payment" or "overpaid".
|
||||
|
||||
In the API, the "Debit" column is represented by the "transaction" resource listed on this page.
|
||||
In many cases, the left column *usually* also matches the data returned by the :ref:`rest-invoices` resource, but there
|
||||
are two important differences:
|
||||
|
||||
- pretix may be configured such that an invoice is not always generated for an order. In this case, only the transactions
|
||||
return the full data set.
|
||||
|
||||
- pretix does not enforce a new invoice to be created e.g. when a ticket is changed to a different subevent. However,
|
||||
pretix always creates a new transaction whenever there is a change to a ticket that concerns the **price**, **tax rate**,
|
||||
**product**, or **date** (in an event series).
|
||||
|
||||
The :ref:`rest-orders` themselves are not a good representation of the "Debit" side of the table for accounting
|
||||
purposes since they are not immutable:
|
||||
They will only tell you the current state of the order, not what it was a week ago.
|
||||
|
||||
The "Credit" column is represented by the :ref:`order-payment-resource` and :ref:`order-refund-resource`.
|
||||
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the transaction
|
||||
order string Order code the transaction was created from
|
||||
event string Event slug, only present on organizer-level API calls
|
||||
created datetime The creation time of the transaction in the database
|
||||
datetime datetime The time at which the transaction is financially relevant.
|
||||
This is usually the same as created, but may vary for
|
||||
retroactively created transactions after software bugs or
|
||||
for data that preceeds this data model.
|
||||
positionid integer Number of the position within the order this refers to,
|
||||
is ``null`` for transactions that refer to a fee
|
||||
count integer Number of items purchased, is negative for cancellations
|
||||
item integer The internal ID of the item purchased (or ``null`` for fees)
|
||||
variation integer The internal ID of the variation purchased (or ``null``)
|
||||
subevent integer The internal ID of the event series date (or ``null``)
|
||||
price money (string) Gross price of the transaction
|
||||
tax_rate decimal (string) Tax rate applied in transaction
|
||||
tax_rule integer The internal ID of the tax rule used (or ``null``)
|
||||
tax_code string The selected tax code (or ``null``)
|
||||
tax_value money (string) The computed tax value
|
||||
fee_type string The type of fee (or ``null`` for products)
|
||||
internal_type string Additional type classification of the fee (or ``null`` for products)
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2025.7.0
|
||||
|
||||
This resource was added to the API.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/transactions/
|
||||
|
||||
Returns a list of all transactions of an event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/transactions/ 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": 123,
|
||||
"order": "FOO",
|
||||
"count": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"datetime": "2017-12-01T10:00:00Z",
|
||||
"item": null,
|
||||
"variation": null,
|
||||
"positionid": 1,
|
||||
"price": "23.00",
|
||||
"subevent": null,
|
||||
"tax_code": "E",
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 23,
|
||||
"tax_value": "0.00",
|
||||
"fee_type": null,
|
||||
"internal_type": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string order: Only return transactions matching the given order code.
|
||||
:query datetime_since: Only return transactions with a datetime at or after the given time.
|
||||
:query datetime_before: Only return transactions with a datetime before the given time.
|
||||
:query created_since: Only return transactions with a creation time at or after the given time.
|
||||
:query created_before: Only return transactions with a creation time before the given time.
|
||||
:query item: Only return transactions that match the given item ID.
|
||||
:query item__in: Only return transactions that match one of the given item IDs (separated with a comma).
|
||||
:query variation: Only return transactions that match the given variation ID.
|
||||
:query variation__in: Only return transactions that match one of the given variation IDs (separated with a comma).
|
||||
:query subevent: Only return transactions that match the given subevent ID.
|
||||
:query subevent__in: Only return transactions that match one of the given subevent IDs (separated with a comma).
|
||||
:query tax_rule: Only return transactions that match the given tax rule ID.
|
||||
:query tax_rule__in: Only return transactions that match one of the given tax rule IDs (separated with a comma).
|
||||
:query tax_code: Only return transactions that match the given tax code.
|
||||
:query tax_code__in: Only return transactions that match one of the given tax codes (separated with a comma).
|
||||
:query tax_rate: Only return transactions that match the given tax rate.
|
||||
:query tax_rate__in: Only return transactions that match one of the given tax rates (separated with a comma).
|
||||
:query fee_type: Only return transactions that match the given fee type.
|
||||
:query fee_type__in: Only return transactions that match one of the given fee types (separated with a comma).
|
||||
: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 a valid organizer
|
||||
:param event: The ``slug`` field of a valid event
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/transactions/
|
||||
|
||||
Returns a list of all transactions of an organizer that you have access to.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/transactions/ 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": 123,
|
||||
"event": "sampleconf",
|
||||
"order": "FOO",
|
||||
"count": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"datetime": "2017-12-01T10:00:00Z",
|
||||
"item": null,
|
||||
"variation": null,
|
||||
"positionid": 1,
|
||||
"price": "23.00",
|
||||
"subevent": null,
|
||||
"tax_code": "E",
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 23,
|
||||
"tax_value": "0.00",
|
||||
"fee_type": null,
|
||||
"internal_type": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string event: Only return transactions matching the given event slug.
|
||||
:query string order: Only return transactions matching the given order code.
|
||||
:query datetime_since: Only return transactions with a datetime at or after the given time.
|
||||
:query datetime_before: Only return transactions with a datetime before the given time.
|
||||
:query created_since: Only return transactions with a creation time at or after the given time.
|
||||
:query created_before: Only return transactions with a creation time before the given time.
|
||||
:query item: Only return transactions that match the given item ID.
|
||||
:query item__in: Only return transactions that match one of the given item IDs (separated with a comma).
|
||||
:query variation: Only return transactions that match the given variation ID.
|
||||
:query variation__in: Only return transactions that match one of the given variation IDs (separated with a comma).
|
||||
:query subevent: Only return transactions that match the given subevent ID.
|
||||
:query subevent__in: Only return transactions that match one of the given subevent IDs (separated with a comma).
|
||||
:query tax_rule: Only return transactions that match the given tax rule ID.
|
||||
:query tax_rule__in: Only return transactions that match one of the given tax rule IDs (separated with a comma).
|
||||
:query tax_code: Only return transactions that match the given tax code.
|
||||
:query tax_code__in: Only return transactions that match one of the given tax codes (separated with a comma).
|
||||
:query tax_rate: Only return transactions that match the given tax rate.
|
||||
:query tax_rate__in: Only return transactions that match one of the given tax rates (separated with a comma).
|
||||
:query fee_type: Only return transactions that match the given fee type.
|
||||
:query fee_type__in: Only return transactions that match one of the given fee types (separated with a comma).
|
||||
: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 a valid organizer
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
@@ -49,6 +49,8 @@ subevent integer ID of the date
|
||||
show_hidden_items boolean Only if set to ``true``, this voucher allows to buy products with the property ``hide_without_voucher``. Defaults to ``true``.
|
||||
all_addons_included boolean If set to ``true``, all add-on products for the product purchased with this voucher are included in the base price.
|
||||
all_bundles_included boolean If set to ``true``, all bundled products for the product purchased with this voucher are added without their designated price.
|
||||
budget money (string) The budget a voucher is allowed to consume before being used up (or ``null``)
|
||||
budget_used money (string) The amount of budget the voucher has already used up.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -99,7 +101,9 @@ Endpoints
|
||||
"subevent": null,
|
||||
"show_hidden_items": false,
|
||||
"all_addons_included": false,
|
||||
"all_bundles_included": false
|
||||
"all_bundles_included": false,
|
||||
"budget": None,
|
||||
"budget_used": "0.00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -169,7 +173,9 @@ Endpoints
|
||||
"subevent": null,
|
||||
"show_hidden_items": false,
|
||||
"all_addons_included": false,
|
||||
"all_bundles_included": false
|
||||
"all_bundles_included": false,
|
||||
"budget": None,
|
||||
"budget_used": "0.00"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -239,7 +245,9 @@ Endpoints
|
||||
"subevent": null,
|
||||
"show_hidden_items": false,
|
||||
"all_addons_included": false,
|
||||
"all_bundles_included": false
|
||||
"all_bundles_included": false,
|
||||
"budget": None,
|
||||
"budget_used": "0.00"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a voucher for
|
||||
@@ -376,7 +384,9 @@ Endpoints
|
||||
"subevent": null,
|
||||
"show_hidden_items": false,
|
||||
"all_addons_included": false,
|
||||
"all_bundles_included": false
|
||||
"all_bundles_included": false,
|
||||
"budget": None,
|
||||
"budget_used": "0.00"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
|
||||
200
doc/development/api/datasync.rst
Normal file
200
doc/development/api/datasync.rst
Normal file
@@ -0,0 +1,200 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
Data sync providers
|
||||
===================
|
||||
|
||||
.. warning:: This feature is considered **experimental**. It might change at any time without prior notice.
|
||||
|
||||
pretix provides connectivity to many external services through plugins. A common requirement
|
||||
is unidirectionally sending (order, customer, ticket, ...) data into external systems.
|
||||
The transfer is usually triggered by signals provided by pretix core (e.g. :data:`order_placed`),
|
||||
but performed asynchronously.
|
||||
|
||||
Such plugins should use the :class:`OutboundSyncProvider` API to utilize the queueing, retry and mapping mechanisms as well as the user interface for configuration and monitoring.
|
||||
|
||||
An :class:`OutboundSyncProvider` for registering event participants in a mailing list could start
|
||||
like this, for example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.base.datasync.datasync import OutboundSyncProvider
|
||||
|
||||
class MyListSyncProvider(OutboundSyncProvider):
|
||||
identifier = "my_list"
|
||||
display_name = "My Mailing List Service"
|
||||
# ...c
|
||||
|
||||
|
||||
The plugin must register listeners in `signals.py` for all signals that should to trigger a sync and
|
||||
within it has to call :meth:`MyListSyncProvider.enqueue_order` to enqueue the order for synchronization:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@receiver(order_placed, dispatch_uid="mylist_order_placed")
|
||||
def on_order_placed(sender, order, **kwargs):
|
||||
MyListSyncProvider.enqueue_order(order, "order_placed")
|
||||
|
||||
|
||||
Furthermore, most of these plugins need to translate data from some pretix objects (e.g. orders)
|
||||
into an external system's data structures. Sometimes, there is only one reasonable way or the
|
||||
plugin author makes an opinionated decision what information from which objects should be
|
||||
transferred into which data structures in the external system.
|
||||
|
||||
Otherwise, you can use a :class:`PropertyMappingFormSet` to let the user set up a mapping from pretix model fields
|
||||
to external data fields. You could store the mapping information either in the event settings, or in a separate
|
||||
data model. Your implementation of :attr:`OutboundSyncProvider.mappings`
|
||||
needs to provide a list of mappings, which can be e.g. static objects or model instances, as long as they
|
||||
have at least the properties defined in
|
||||
:class:`pretix.base.datasync.datasync.StaticMapping`.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# class MyListSyncProvider, contd.
|
||||
def mappings(self):
|
||||
return [
|
||||
StaticMapping(
|
||||
id=1, pretix_model='Order', external_object_type='Contact',
|
||||
pretix_id_field='email', external_id_field='email',
|
||||
property_mappings=self.event.settings.mylist_order_mapping,
|
||||
))
|
||||
]
|
||||
|
||||
|
||||
Currently, we support `orders` and `order positions` as data sources, with the data fields defined in
|
||||
:func:`pretix.base.datasync.sourcefields.get_data_fields`.
|
||||
|
||||
To perform the actual sync, implement :func:`sync_object_with_properties` and optionally
|
||||
:func:`finalize_sync_order`. The former is called for each object to be created according to the ``mappings``.
|
||||
For each order that was enqueued using :func:`enqueue_order`:
|
||||
|
||||
- each Mapping with ``pretix_model == "Order"`` results in one call to :func:`sync_object_with_properties`,
|
||||
- each Mapping with ``pretix_model == "OrderPosition"`` results in one call to
|
||||
:func:`sync_object_with_properties` per order position,
|
||||
- :func:`finalize_sync_order` is called one time after all calls to :func:`sync_object_with_properties`.
|
||||
|
||||
|
||||
Implementation examples
|
||||
-----------------------
|
||||
|
||||
For example implementations, see the test cases in :mod:`tests.base.test_datasync`.
|
||||
|
||||
In :class:`SimpleOrderSync`, a basic data transfer of order data only is
|
||||
shown. Therein, a ``sync_object_with_properties`` method is defined as follows:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.base.datasync.utils import assign_properties
|
||||
|
||||
def sync_object_with_properties(
|
||||
self, external_id_field, id_value, properties: list, inputs: dict,
|
||||
mapping, mapped_objects: dict, **kwargs,
|
||||
):
|
||||
# First, we query the external service if our object-to-sync already exists there.
|
||||
# This is necessary to make sure our method is idempotent, i.e. handles already synced
|
||||
# data gracefully.
|
||||
pre_existing_object = self.fake_api_client.retrieve_object(
|
||||
mapping.external_object_type,
|
||||
external_id_field,
|
||||
id_value
|
||||
)
|
||||
|
||||
# We use the helper function ``assign_properties`` to update a pre-existing object.
|
||||
update_values = assign_properties(
|
||||
new_values=properties,
|
||||
old_values=pre_existing_object or {},
|
||||
is_new=pre_existing_object is None,
|
||||
list_sep=";",
|
||||
)
|
||||
|
||||
# Then we can send our new data to the external service. The specifics of course depends
|
||||
# on your API, e.g. you may need to use different endpoints for creating or updating an
|
||||
# object, or pass the identifier separately instead of in the same dictionary as the
|
||||
# other properties.
|
||||
result = self.fake_api_client.create_or_update_object(mapping.external_object_type, {
|
||||
**update_values,
|
||||
external_id_field: id_value,
|
||||
"_id": pre_existing_object and pre_existing_object.get("_id"),
|
||||
})
|
||||
|
||||
# Finally, return a dictionary containing at least `object_type`, `external_id_field`,
|
||||
# `id_value`, `external_link_href`, and `external_link_display_name` keys.
|
||||
# Further keys may be provided for your internal use. This dictionary is provided
|
||||
# in following calls in the ``mapped_objects`` dict, to allow creating associations
|
||||
# to this object.
|
||||
return {
|
||||
"object_type": mapping.external_object_type,
|
||||
"external_id_field": external_id_field,
|
||||
"id_value": id_value,
|
||||
"external_link_href": f"https://example.org/external-system/{mapping.external_object_type}/{id_value}/",
|
||||
"external_link_display_name": f"Contact #{id_value} - Jane Doe",
|
||||
"my_result": result,
|
||||
}
|
||||
|
||||
.. note:: The result dictionaries of earlier invocations of :func:`sync_object_with_properties` are
|
||||
only provided in subsequent calls of the same sync run, such that a mapping can
|
||||
refer to e.g. the external id of an object created by a preceding mapping.
|
||||
However, the result dictionaries are currently not provided across runs. This will
|
||||
likely change in a future revision of this API, to allow easier integration of external
|
||||
systems that do not allow retrieving/updating data by a pretix-provided key.
|
||||
|
||||
``mapped_objects`` is a dictionary of lists of dictionaries. The keys to the dictionary are
|
||||
the mapping identifiers (``mapping.id``), the lists contain the result dictionaries returned
|
||||
by :func:`sync_object_with_properties`.
|
||||
|
||||
|
||||
In :class:`OrderAndTicketAssociationSync`, an example is given where orders, order positions,
|
||||
and the association between them are transferred.
|
||||
|
||||
|
||||
The OutboundSyncProvider base class
|
||||
-----------------------------------
|
||||
|
||||
.. autoclass:: pretix.base.datasync.datasync.OutboundSyncProvider
|
||||
:members:
|
||||
|
||||
|
||||
Property mapping format
|
||||
-----------------------
|
||||
|
||||
To allow the user to configure property mappings, you can use the PropertyMappingFormSet,
|
||||
which will generate the required ``property_mappings`` value automatically. If you need
|
||||
to specify the property mappings programmatically, you can refer to the description below
|
||||
on their format.
|
||||
|
||||
.. autoclass:: pretix.control.forms.mapping.PropertyMappingFormSet
|
||||
:members: to_property_mappings_json
|
||||
|
||||
A simple JSON-serialized ``property_mappings`` list for mapping some order information can look like this:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
[
|
||||
{
|
||||
"pretix_field": "email",
|
||||
"external_field": "orderemail",
|
||||
"value_map": "",
|
||||
"overwrite": "overwrite",
|
||||
},
|
||||
{
|
||||
"pretix_field": "order_status",
|
||||
"external_field": "status",
|
||||
"value_map": "{\"n\": \"pending\", \"p\": \"paid\", \"e\": \"expired\", \"c\": \"canceled\", \"r\": \"refunded\"}",
|
||||
"overwrite": "overwrite",
|
||||
},
|
||||
{
|
||||
"pretix_field": "order_total",
|
||||
"external_field": "total",
|
||||
"value_map": "",
|
||||
"overwrite": "overwrite",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Translating mappings on Event copy
|
||||
----------------------------------
|
||||
|
||||
Property mappings can contain references to event-specific primary keys. Therefore, plugins must register to the
|
||||
event_copy_data signal and call translate_property_mappings on all property mappings they store.
|
||||
|
||||
.. autofunction:: pretix.base.datasync.utils.translate_property_mappings
|
||||
@@ -18,5 +18,6 @@ Contents:
|
||||
customview
|
||||
cookieconsent
|
||||
auth
|
||||
datasync
|
||||
general
|
||||
quality
|
||||
|
||||
@@ -5,7 +5,7 @@ Development setup
|
||||
|
||||
This tutorial helps you to get started hacking with pretix on your own computer. You need this to
|
||||
be able to contribute to pretix, but it might also be helpful if you want to write your own plugins.
|
||||
If you want to install pretix on a server for actual usage, go to the [administrator documentation](https://docs.pretix.eu/self-hosting/) instead.
|
||||
If you want to install pretix on a server for actual usage, go to the `administrator documentation`_ instead.
|
||||
|
||||
Obtain a copy of the source code
|
||||
--------------------------------
|
||||
@@ -221,3 +221,4 @@ your virtual environment.::
|
||||
|
||||
.. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver
|
||||
.. _pretixdroid: https://github.com/pretix/pretixdroid
|
||||
.. _administrator documentation: https://docs.pretix.eu/self-hosting/
|
||||
|
||||
@@ -33,10 +33,10 @@ dependencies = [
|
||||
"celery==5.5.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=44.0.0",
|
||||
"css-inline==0.15.*",
|
||||
"css-inline==0.17.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"Django[argon2]==4.2.*,>=4.2.15",
|
||||
"django-bootstrap3==25.1",
|
||||
"django-bootstrap3==25.2",
|
||||
"django-compressor==4.5.1",
|
||||
"django-countries==7.6.*",
|
||||
"django-filter==25.1",
|
||||
@@ -74,24 +74,24 @@ dependencies = [
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.10.*",
|
||||
"phonenumberslite==9.0.*",
|
||||
"Pillow==11.2.*",
|
||||
"Pillow==11.3.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==6.31.*",
|
||||
"psycopg2-binary",
|
||||
"pycountry",
|
||||
"pycparser==2.22",
|
||||
"pycryptodome==3.23.*",
|
||||
"pypdf==5.6.*",
|
||||
"pypdf==5.9.*",
|
||||
"python-bidi==0.6.*", # Support for Arabic in reportlab
|
||||
"python-dateutil==2.9.*",
|
||||
"pytz",
|
||||
"pytz-deprecation-shim==0.1.*",
|
||||
"pyuca",
|
||||
"qrcode==8.2",
|
||||
"redis==6.2.*",
|
||||
"redis==6.3.*",
|
||||
"reportlab==4.4.*",
|
||||
"requests==2.31.*",
|
||||
"sentry-sdk==2.31.*",
|
||||
"sentry-sdk==2.34.*",
|
||||
"sepaxml==2.6.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
@@ -122,7 +122,7 @@ dev = [
|
||||
"pytest-django==4.*",
|
||||
"pytest-mock==3.14.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.7.*",
|
||||
"pytest-xdist==3.8.*",
|
||||
"pytest==8.4.*",
|
||||
"responses",
|
||||
]
|
||||
|
||||
@@ -115,6 +115,7 @@ ALL_LANGUAGES = [
|
||||
('sk', _('Slovak')),
|
||||
('sv', _('Swedish')),
|
||||
('es', _('Spanish')),
|
||||
('es-419', _('Spanish (Latin America)')),
|
||||
('tr', _('Turkish')),
|
||||
('uk', _('Ukrainian')),
|
||||
]
|
||||
@@ -172,6 +173,12 @@ EXTRA_LANG_INFO = {
|
||||
'name': 'Norwegian Bokmal',
|
||||
'name_local': 'norsk (bokmål)',
|
||||
},
|
||||
'es-419': {
|
||||
'bidi': False,
|
||||
'code': 'es-419',
|
||||
'name': 'Spanish (Latin America)',
|
||||
'name_local': 'Español',
|
||||
},
|
||||
}
|
||||
|
||||
django.conf.locale.LANG_INFO.update(EXTRA_LANG_INFO)
|
||||
|
||||
@@ -582,7 +582,7 @@ class QuotaSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Quota
|
||||
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out',
|
||||
'release_after_exit', 'available', 'available_number')
|
||||
'release_after_exit', 'available', 'available_number', 'ignore_for_event_availability')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -56,7 +56,7 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
PrintLog, RevokedTicketSecret,
|
||||
PrintLog, RevokedTicketSecret, Transaction,
|
||||
)
|
||||
from pretix.base.pdf import get_images, get_variables
|
||||
from pretix.base.services.cart import error_messages
|
||||
@@ -1783,3 +1783,23 @@ class BlockedTicketSecretSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = BlockedTicketSecret
|
||||
fields = ('id', 'secret', 'updated', 'blocked')
|
||||
|
||||
|
||||
class TransactionSerializer(I18nAwareModelSerializer):
|
||||
order = serializers.SlugRelatedField(slug_field="code", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = (
|
||||
"id", "order", "created", "datetime", "positionid", "count", "item", "variation",
|
||||
"subevent", "price", "tax_rate", "tax_rule", "tax_code", "tax_value", "fee_type",
|
||||
"internal_type"
|
||||
)
|
||||
|
||||
|
||||
class OrganizerTransactionSerializer(TransactionSerializer):
|
||||
event = serializers.SlugRelatedField(source="order.event", slug_field="slug", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = TransactionSerializer.Meta.fields + ("event",)
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
@@ -64,14 +66,15 @@ class SeatGuidField(serializers.CharField):
|
||||
|
||||
class VoucherSerializer(I18nAwareModelSerializer):
|
||||
seat = SeatGuidField(allow_null=True, required=False)
|
||||
budget_used = serializers.DecimalField(read_only=True, max_digits=13, decimal_places=2, min_value=Decimal('0.00'))
|
||||
|
||||
class Meta:
|
||||
model = Voucher
|
||||
fields = ('id', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota',
|
||||
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
|
||||
'tag', 'comment', 'subevent', 'show_hidden_items', 'seat', 'all_addons_included',
|
||||
'all_bundles_included')
|
||||
read_only_fields = ('id', 'redeemed')
|
||||
'all_bundles_included', 'budget', 'budget_used')
|
||||
read_only_fields = ('id', 'redeemed', 'budget_used')
|
||||
list_serializer_class = VoucherListSerializer
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
@@ -66,6 +66,7 @@ orga_router.register(r'orders', order.OrganizerOrderViewSet)
|
||||
orga_router.register(r'invoices', order.InvoiceViewSet)
|
||||
orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportViewSet)
|
||||
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
|
||||
orga_router.register(r'transactions', order.OrganizerTransactionViewSet)
|
||||
|
||||
team_router = routers.DefaultRouter()
|
||||
team_router.register(r'members', organizer.TeamMemberViewSet)
|
||||
@@ -83,6 +84,7 @@ event_router.register(r'quotas', item.QuotaViewSet)
|
||||
event_router.register(r'vouchers', voucher.VoucherViewSet)
|
||||
event_router.register(r'orders', order.EventOrderViewSet)
|
||||
event_router.register(r'orderpositions', order.OrderPositionViewSet)
|
||||
event_router.register(r'transactions', order.TransactionViewSet)
|
||||
event_router.register(r'invoices', order.InvoiceViewSet)
|
||||
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
|
||||
event_router.register(r'blockedsecrets', order.BlockedSecretViewSet, basename='blockedsecrets')
|
||||
|
||||
@@ -57,9 +57,9 @@ from pretix.api.serializers.order import (
|
||||
BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer,
|
||||
OrderPaymentCreateSerializer, OrderPaymentSerializer,
|
||||
OrderPositionSerializer, OrderRefundCreateSerializer,
|
||||
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer,
|
||||
PrintLogSerializer, RevokedTicketSecretSerializer,
|
||||
SimulatedOrderSerializer,
|
||||
OrderRefundSerializer, OrderSerializer, OrganizerTransactionSerializer,
|
||||
PriceCalcSerializer, PrintLogSerializer, RevokedTicketSecretSerializer,
|
||||
SimulatedOrderSerializer, TransactionSerializer,
|
||||
)
|
||||
from pretix.api.serializers.orderchange import (
|
||||
BlockNameSerializer, OrderChangeOperationSerializer,
|
||||
@@ -80,6 +80,7 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, PrintLog, QuestionAnswer, RevokedTicketSecret,
|
||||
Transaction,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.pdf import get_images
|
||||
@@ -2030,3 +2031,61 @@ class BlockedSecretViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
return BlockedTicketSecret.objects.filter(event=self.request.event)
|
||||
|
||||
|
||||
with scopes_disabled():
|
||||
class TransactionFilter(FilterSet):
|
||||
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
|
||||
event = django_filters.CharFilter(field_name='order__event', lookup_expr='slug__iexact')
|
||||
datetime_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
|
||||
datetime_before = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='lt')
|
||||
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
|
||||
created_before = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='lt')
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = {
|
||||
'item': ['exact', 'in'],
|
||||
'variation': ['exact', 'in'],
|
||||
'subevent': ['exact', 'in'],
|
||||
'tax_rule': ['exact', 'in'],
|
||||
'tax_code': ['exact', 'in'],
|
||||
'tax_rate': ['exact', 'in'],
|
||||
'fee_type': ['exact', 'in'],
|
||||
}
|
||||
|
||||
|
||||
class TransactionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = TransactionSerializer
|
||||
queryset = Transaction.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
ordering = ('datetime', 'pk')
|
||||
ordering_fields = ('datetime', 'created', 'id',)
|
||||
filterset_class = TransactionFilter
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
return Transaction.objects.filter(order__event=self.request.event).select_related("order")
|
||||
|
||||
|
||||
class OrganizerTransactionViewSet(TransactionViewSet):
|
||||
serializer_class = OrganizerTransactionSerializer
|
||||
permission = None
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Transaction.objects.filter(
|
||||
order__event__organizer=self.request.organizer
|
||||
).select_related("order", "order__event")
|
||||
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
qs = qs.filter(
|
||||
order__event__in=self.request.auth.get_events_with_permission("can_view_orders"),
|
||||
)
|
||||
elif self.request.user.is_authenticated:
|
||||
qs = qs.filter(
|
||||
order__event__in=self.request.user.get_events_with_permission("can_view_orders", request=self.request)
|
||||
)
|
||||
else:
|
||||
raise PermissionDenied("Unknown authentication scheme")
|
||||
|
||||
return qs
|
||||
|
||||
@@ -46,7 +46,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import invoice # NOQA
|
||||
from . import notifications # NOQA
|
||||
from . import email # NOQA
|
||||
from .services import auth, checkin, currencies, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .services import auth, checkin, currencies, datasync, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .models import _transactions # NOQA
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
|
||||
params['client_id'] = provider.configuration['client_id']
|
||||
params['client_secret'] = provider.configuration['client_secret']
|
||||
|
||||
resp = None
|
||||
try:
|
||||
resp = requests.post(
|
||||
endpoint,
|
||||
@@ -214,7 +215,10 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except RequestException:
|
||||
logger.exception('Could not retrieve authorization token')
|
||||
if resp:
|
||||
logger.exception(f'Could not retrieve authorization token. Response: {resp.text}')
|
||||
else:
|
||||
logger.exception('Could not retrieve authorization token')
|
||||
raise ValidationError(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='could not reach login provider',
|
||||
@@ -222,6 +226,7 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
|
||||
)
|
||||
|
||||
if 'access_token' not in data:
|
||||
logger.error(f'Could not find access token. Response: {data}')
|
||||
raise ValidationError(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='access token missing',
|
||||
@@ -229,6 +234,7 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
|
||||
)
|
||||
|
||||
endpoint = provider.configuration['provider_config']['userinfo_endpoint']
|
||||
resp = None
|
||||
try:
|
||||
# https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
|
||||
resp = requests.get(
|
||||
@@ -240,7 +246,10 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
|
||||
resp.raise_for_status()
|
||||
userinfo = resp.json()
|
||||
except RequestException:
|
||||
logger.exception('Could not retrieve user info')
|
||||
if resp:
|
||||
logger.exception(f'Could not retrieve user info. Response: {resp.text}')
|
||||
else:
|
||||
logger.exception('Could not retrieve user info')
|
||||
raise ValidationError(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='could not fetch user info',
|
||||
|
||||
21
src/pretix/base/datasync/__init__.py
Normal file
21
src/pretix/base/datasync/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
436
src/pretix/base/datasync/datasync.py
Normal file
436
src/pretix/base/datasync/datasync.py
Normal file
@@ -0,0 +1,436 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
from datetime import timedelta
|
||||
from functools import cached_property
|
||||
from typing import List, Optional, Protocol
|
||||
|
||||
import sentry_sdk
|
||||
from django.db import DatabaseError, transaction
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.datasync.sourcefields import (
|
||||
EVENT, EVENT_OR_SUBEVENT, ORDER, ORDER_POSITION, get_data_fields,
|
||||
)
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.logentrytype_registry import make_link
|
||||
from pretix.base.models.datasync import OrderSyncQueue, OrderSyncResult
|
||||
from pretix.base.signals import EventPluginRegistry
|
||||
from pretix.helpers import OF_SELF
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
datasync_providers = EventPluginRegistry({"identifier": lambda o: o.identifier})
|
||||
|
||||
|
||||
class BaseSyncError(Exception):
|
||||
def __init__(self, messages, full_message=None):
|
||||
self.messages = messages
|
||||
self.full_message = full_message
|
||||
|
||||
|
||||
class UnrecoverableSyncError(BaseSyncError):
|
||||
"""
|
||||
A SyncProvider encountered a permanent problem, where a retry will not be successful.
|
||||
"""
|
||||
failure_mode = "permanent"
|
||||
|
||||
|
||||
class SyncConfigError(UnrecoverableSyncError):
|
||||
"""
|
||||
A SyncProvider is misconfigured in a way where a retry without configuration change will
|
||||
not be successful.
|
||||
"""
|
||||
failure_mode = "config"
|
||||
|
||||
|
||||
class RecoverableSyncError(BaseSyncError):
|
||||
"""
|
||||
A SyncProvider has encountered a temporary problem, and the sync should be retried
|
||||
at a later time.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ObjectMapping(Protocol):
|
||||
id: int
|
||||
pretix_model: str
|
||||
external_object_type: str
|
||||
pretix_id_field: str
|
||||
external_id_field: str
|
||||
property_mappings: str
|
||||
|
||||
|
||||
StaticMapping = namedtuple('StaticMapping', ('id', 'pretix_model', 'external_object_type', 'pretix_id_field', 'external_id_field', 'property_mappings'))
|
||||
|
||||
|
||||
class OutboundSyncProvider:
|
||||
max_attempts = 5
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def display_name(cls):
|
||||
return str(cls.identifier)
|
||||
|
||||
@classmethod
|
||||
def enqueue_order(cls, order, triggered_by, not_before=None):
|
||||
"""
|
||||
Adds an order to the sync queue. May only be called on derived classes which define an ``identifier`` attribute.
|
||||
|
||||
Should be called in the appropriate signal receivers, e.g.::
|
||||
|
||||
@receiver(order_placed, dispatch_uid="mysync_order_placed")
|
||||
def on_order_placed(sender, order, **kwargs):
|
||||
MySyncProvider.enqueue_order(order, "order_placed")
|
||||
|
||||
:param order: the Order that should be synced
|
||||
:param triggered_by: the reason why the order should be synced, e.g. name of the signal
|
||||
(currently only used internally for logging)
|
||||
"""
|
||||
if not hasattr(cls, 'identifier'):
|
||||
raise TypeError('Call this method on a derived class that defines an "identifier" attribute.')
|
||||
OrderSyncQueue.objects.update_or_create(
|
||||
order=order,
|
||||
sync_provider=cls.identifier,
|
||||
in_flight=False,
|
||||
defaults={
|
||||
"event": order.event,
|
||||
"triggered_by": triggered_by,
|
||||
"not_before": not_before or now(),
|
||||
"need_manual_retry": None,
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_external_link_info(cls, event, external_link_href, external_link_display_name):
|
||||
return {
|
||||
"href": external_link_href,
|
||||
"val": external_link_display_name,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_external_link_html(cls, event, external_link_href, external_link_display_name):
|
||||
info = cls.get_external_link_info(event, external_link_href, external_link_display_name)
|
||||
return make_link(info, '{val}')
|
||||
|
||||
def next_retry_date(self, sq):
|
||||
"""
|
||||
Optionally override to configure a different retry backoff behavior
|
||||
"""
|
||||
return now() + timedelta(hours=1)
|
||||
|
||||
def should_sync_order(self, order):
|
||||
"""
|
||||
Optionally override this method to exclude certain orders from sync by returning ``False``
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def mappings(self):
|
||||
"""
|
||||
Implementations must override this property to provide the data mappings as a list of objects.
|
||||
|
||||
They can return instances of the ``StaticMapping`` `namedtuple` defined above, or create their own
|
||||
class (e.g. a Django model).
|
||||
|
||||
:return: The returned objects must have at least the following properties:
|
||||
|
||||
- `id`: Unique identifier for this mapping. If the mappings are Django models, the database primary key
|
||||
should be used. This may be referenced in other mappings, to establish relations between objects.
|
||||
- `pretix_model`: Which pretix model to use as data source in this mapping. Possible values are
|
||||
the keys of ``sourcefields.AVAILABLE_MODELS``
|
||||
- `external_object_type`: Destination object type in the target system. opaque string of maximum 128 characters.
|
||||
- `pretix_id_field`: Which pretix data field should be used to identify the mapped object. Any ``DataFieldInfo.key``
|
||||
returned by ``sourcefields.get_data_fields()`` for the combination of ``Event`` and ``pretix_model``.
|
||||
- `external_id_field`: Destination identifier field in the target system.
|
||||
- `property_mappings`: Mapping configuration as generated by ``PropertyMappingFormSet.to_property_mappings_list()``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def sync_queued_orders(self, queued_orders):
|
||||
"""
|
||||
This method should catch all Exceptions and handle them appropriately. It should never throw
|
||||
an Exception, as that may block the entire queue.
|
||||
"""
|
||||
for queue_item in queued_orders:
|
||||
with transaction.atomic():
|
||||
try:
|
||||
sq = (
|
||||
OrderSyncQueue.objects
|
||||
.select_for_update(of=OF_SELF, nowait=True)
|
||||
.select_related("order")
|
||||
.get(pk=queue_item.pk)
|
||||
)
|
||||
if sq.in_flight:
|
||||
continue
|
||||
sq.in_flight = True
|
||||
sq.in_flight_since = now()
|
||||
sq.save()
|
||||
except DatabaseError:
|
||||
# Either select_for_update failed to lock the row, or we couldn't set in_flight
|
||||
# as this order is already in flight (UNIQUE violation). In either case, we ignore
|
||||
# this order for now.
|
||||
continue
|
||||
|
||||
try:
|
||||
mapped_objects = self.sync_order(sq.order)
|
||||
if not all(all(not res or res.sync_info.get("action", "") == "nothing_to_do" for res in res_list) for res_list in mapped_objects.values()):
|
||||
sq.order.log_action("pretix.event.order.data_sync.success", {
|
||||
"provider": self.identifier,
|
||||
"objects": {
|
||||
mapping_id: [osr and osr.to_result_dict() for osr in results]
|
||||
for mapping_id, results in mapped_objects.items()
|
||||
},
|
||||
})
|
||||
sq.delete()
|
||||
except UnrecoverableSyncError as e:
|
||||
sq.set_sync_error(e.failure_mode, e.messages, e.full_message)
|
||||
except RecoverableSyncError as e:
|
||||
sq.failed_attempts += 1
|
||||
sq.not_before = self.next_retry_date(sq)
|
||||
# model changes saved by set_sync_error / clear_in_flight calls below
|
||||
if sq.failed_attempts >= self.max_attempts:
|
||||
logger.exception('Failed to sync order (max attempts exceeded)')
|
||||
sentry_sdk.capture_exception(e)
|
||||
sq.set_sync_error("exceeded", e.messages, e.full_message)
|
||||
else:
|
||||
logger.info(
|
||||
f"Could not sync order {sq.order.code} to {type(self).__name__} "
|
||||
f"(transient error, attempt #{sq.failed_attempts}, next {sq.not_before})",
|
||||
exc_info=True,
|
||||
)
|
||||
sq.clear_in_flight()
|
||||
except Exception as e:
|
||||
logger.exception('Failed to sync order (unhandled exception)')
|
||||
sentry_sdk.capture_exception(e)
|
||||
sq.set_sync_error("internal", [], str(e))
|
||||
|
||||
@cached_property
|
||||
def data_fields(self):
|
||||
return {
|
||||
f.key: f
|
||||
for f in get_data_fields(self.event)
|
||||
}
|
||||
|
||||
def get_field_value(self, inputs, mapping_entry):
|
||||
key = mapping_entry["pretix_field"]
|
||||
try:
|
||||
field = self.data_fields[key]
|
||||
except KeyError:
|
||||
with language(self.event.settings.locale):
|
||||
raise SyncConfigError([_(
|
||||
'Field "{field_name}" is not valid for {available_inputs}. Please check your {provider_name} settings.'
|
||||
).format(key=key, available_inputs="/".join(inputs.keys()), provider_name=self.display_name)])
|
||||
input = inputs[field.required_input]
|
||||
val = field.getter(input)
|
||||
if isinstance(val, list):
|
||||
if field.enum_opts and mapping_entry.get("value_map"):
|
||||
map = json.loads(mapping_entry["value_map"])
|
||||
try:
|
||||
val = [map[el] for el in val]
|
||||
except KeyError:
|
||||
with language(self.event.settings.locale):
|
||||
raise SyncConfigError([_(
|
||||
'Please update value mapping for field "{field_name}" - option "{val}" not assigned'
|
||||
).format(field_name=key, val=val)])
|
||||
|
||||
val = ",".join(val)
|
||||
return val
|
||||
|
||||
def get_properties(self, inputs: dict, property_mappings: List[dict]):
|
||||
return [
|
||||
(m["external_field"], self.get_field_value(inputs, m), m["overwrite"])
|
||||
for m in property_mappings
|
||||
]
|
||||
|
||||
def sync_object_with_properties(
|
||||
self,
|
||||
external_id_field: str,
|
||||
id_value,
|
||||
properties: list,
|
||||
inputs: dict,
|
||||
mapping: ObjectMapping,
|
||||
mapped_objects: dict,
|
||||
**kwargs,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
This method is called for each object that needs to be created/updated in the external system -- which these are is
|
||||
determined by the implementation of the `mapping` property.
|
||||
|
||||
:param external_id_field: Identifier field in the external system as provided in ``mapping.external_identifier``
|
||||
:param id_value: Identifier contents as retrieved from the property specified by ``mapping.pretix_identifier`` of the model
|
||||
specified by ``mapping.pretix_model``
|
||||
:param properties: All properties defined in ``mapping.property_mappings``, as list of three-tuples
|
||||
``(external_field, value, overwrite)``
|
||||
:param inputs: All pretix model instances from which data can be retrieved for this mapping.
|
||||
Dictionary mapping from sourcefields.ORDER_POSITION, .ORDER, .EVENT, .EVENT_OR_SUBEVENT to the
|
||||
relevant Django model.
|
||||
Most providers don't need to use this parameter directly, as `properties` and `id_value`
|
||||
already contain the values as evaluated from the available inputs.
|
||||
:param mapping: The mapping object as returned by ``self.mappings``
|
||||
:param mapped_objects: Information about objects that were synced in the same sync run, by mapping definitions
|
||||
*before* the current one in order of ``self.mappings``.
|
||||
Type is a dictionary ``{mapping.id: [list of OrderSyncResult objects]}``
|
||||
Useful to create associations between objects in the target system.
|
||||
|
||||
Example code to create return value::
|
||||
|
||||
return {
|
||||
# optional:
|
||||
"action": "nothing_to_do", # to inform that no action was taken, because the data was already up-to-date.
|
||||
# other values for action (e.g. create, update) currently have no special
|
||||
# meaning, but are visible for debugging purposes to admins.
|
||||
|
||||
# optional:
|
||||
"external_link_href": "https://external-system.example.com/backend/link/to/contact/123/",
|
||||
"external_link_display_name": "Contact #123 - Jane Doe",
|
||||
"...optionally further values you need in mapped_objects for association": 123456789,
|
||||
}
|
||||
|
||||
The return value needs to be a JSON serializable dict, or None.
|
||||
|
||||
Return None only in case you decide this object should not be synced at all in this mapping. Do not return None in
|
||||
case the object is already up-to-date in the target system (return "action": "nothing_to_do" instead).
|
||||
|
||||
This method needs to be idempotent, i.e. calling it multiple times with the same input values should create
|
||||
only a single object in the target system.
|
||||
|
||||
Subsequent calls with the same mapping and id_value should update the existing object, instead of creating a new one.
|
||||
In a SQL database, you might use an `INSERT OR UPDATE` or `UPSERT` statement; many REST APIs provide an equivalent API call.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def sync_object(
|
||||
self,
|
||||
inputs: dict,
|
||||
mapping,
|
||||
mapped_objects: dict,
|
||||
):
|
||||
logger.debug("Syncing object %r, %r, %r", inputs, mapping, mapped_objects)
|
||||
properties = self.get_properties(inputs, mapping.property_mappings)
|
||||
logger.debug("Properties: %r", properties)
|
||||
|
||||
id_value = self.get_field_value(inputs, {"pretix_field": mapping.pretix_id_field})
|
||||
if not id_value:
|
||||
return None
|
||||
|
||||
info = self.sync_object_with_properties(
|
||||
external_id_field=mapping.external_id_field,
|
||||
id_value=id_value,
|
||||
properties=properties,
|
||||
inputs=inputs,
|
||||
mapping=mapping,
|
||||
mapped_objects=mapped_objects,
|
||||
)
|
||||
if not info:
|
||||
return None
|
||||
external_link_href = info.pop('external_link_href', None)
|
||||
external_link_display_name = info.pop('external_link_display_name', None)
|
||||
obj, created = OrderSyncResult.objects.update_or_create(
|
||||
order=inputs.get(ORDER), order_position=inputs.get(ORDER_POSITION), sync_provider=self.identifier,
|
||||
mapping_id=mapping.id,
|
||||
defaults=dict(
|
||||
external_object_type=mapping.external_object_type,
|
||||
external_id_field=mapping.external_id_field,
|
||||
id_value=id_value,
|
||||
external_link_href=external_link_href,
|
||||
external_link_display_name=external_link_display_name,
|
||||
sync_info=info,
|
||||
transmitted=now(),
|
||||
)
|
||||
)
|
||||
return obj
|
||||
|
||||
def sync_order(self, order):
|
||||
if not self.should_sync_order(order):
|
||||
logger.debug("Skipping order %r", order)
|
||||
return
|
||||
|
||||
logger.debug("Syncing order %r", order)
|
||||
positions = list(
|
||||
order.all_positions
|
||||
.prefetch_related("answers", "answers__question")
|
||||
.select_related(
|
||||
"voucher",
|
||||
)
|
||||
)
|
||||
order_inputs = {ORDER: order, EVENT: self.event}
|
||||
mapped_objects = {}
|
||||
for mapping in self.mappings:
|
||||
if mapping.pretix_model == 'Order':
|
||||
mapped_objects[mapping.id] = [
|
||||
self.sync_object(order_inputs, mapping, mapped_objects)
|
||||
]
|
||||
elif mapping.pretix_model == 'OrderPosition':
|
||||
mapped_objects[mapping.id] = [
|
||||
self.sync_object({
|
||||
**order_inputs, EVENT_OR_SUBEVENT: op.subevent or self.event, ORDER_POSITION: op
|
||||
}, mapping, mapped_objects)
|
||||
for op in positions
|
||||
]
|
||||
else:
|
||||
raise SyncConfigError("Invalid pretix model '{}'".format(mapping.pretix_model))
|
||||
self.finalize_sync_order(order)
|
||||
return mapped_objects
|
||||
|
||||
def filter_mapped_objects(self, mapped_objects, inputs):
|
||||
"""
|
||||
For order positions, only
|
||||
"""
|
||||
if ORDER_POSITION in inputs:
|
||||
return {
|
||||
mapping_id: [
|
||||
osr for osr in results
|
||||
if osr and (osr.order_position_id is None or osr.order_position_id == inputs[ORDER_POSITION].id)
|
||||
]
|
||||
for mapping_id, results in mapped_objects.items()
|
||||
}
|
||||
else:
|
||||
return mapped_objects
|
||||
|
||||
def finalize_sync_order(self, order):
|
||||
"""
|
||||
Called after ``sync_object`` has been called successfully for all objects of a specific order. Can
|
||||
be used for saving bulk information per order.
|
||||
"""
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Called after all orders of an event have been synced. Can be used for clean-up tasks (e.g. closing
|
||||
a session).
|
||||
"""
|
||||
pass
|
||||
547
src/pretix/base/datasync/sourcefields.py
Normal file
547
src/pretix/base/datasync/sourcefields.py
Normal file
@@ -0,0 +1,547 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from collections import namedtuple
|
||||
from functools import partial
|
||||
|
||||
from django.db.models import Max, Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import Checkin, InvoiceAddress, Order, Question
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
|
||||
def get_answer(op, question_identifier=None):
|
||||
a = None
|
||||
if op.addon_to:
|
||||
if "answers" in getattr(op.addon_to, "_prefetched_objects_cache", {}):
|
||||
try:
|
||||
a = [
|
||||
a
|
||||
for a in op.addon_to.answers.all()
|
||||
if a.question.identifier == question_identifier
|
||||
][0]
|
||||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
a = op.addon_to.answers.filter(
|
||||
question__identifier=question_identifier
|
||||
).first()
|
||||
|
||||
if "answers" in getattr(op, "_prefetched_objects_cache", {}):
|
||||
try:
|
||||
a = [
|
||||
a
|
||||
for a in op.answers.all()
|
||||
if a.question.identifier == question_identifier
|
||||
][0]
|
||||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
a = op.answers.filter(question__identifier=question_identifier).first()
|
||||
|
||||
if not a:
|
||||
return ""
|
||||
else:
|
||||
if a.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
return [str(o.identifier) for o in a.options.all()]
|
||||
if a.question.type == Question.TYPE_BOOLEAN:
|
||||
return a.answer == "True"
|
||||
return a.answer
|
||||
|
||||
|
||||
def get_payment_date(order):
|
||||
if order.status == Order.STATUS_PENDING:
|
||||
return None
|
||||
|
||||
return isoformat_or_none(order.payments.aggregate(m=Max("payment_date"))["m"])
|
||||
|
||||
|
||||
def isoformat_or_none(dt):
|
||||
return dt and dt.isoformat()
|
||||
|
||||
|
||||
def first_checkin_on_list(list_pk, position):
|
||||
checkin = position.checkins.filter(
|
||||
list__pk=list_pk, type=Checkin.TYPE_ENTRY
|
||||
).first()
|
||||
if checkin:
|
||||
return isoformat_or_none(checkin.datetime)
|
||||
|
||||
|
||||
def split_name_on_last_space(name, part):
|
||||
name_parts = name.rsplit(" ", 1)
|
||||
return name_parts[part] if len(name_parts) > part else ""
|
||||
|
||||
|
||||
def normalize_email(email):
|
||||
if email:
|
||||
local, host = email.split("@")
|
||||
host = host.encode("idna").decode()
|
||||
return f"{local}@{host}"
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
ORDER_POSITION = 'position'
|
||||
ORDER = 'order'
|
||||
EVENT = 'event'
|
||||
EVENT_OR_SUBEVENT = 'event_or_subevent'
|
||||
AVAILABLE_MODELS = {
|
||||
'OrderPosition': (ORDER_POSITION, ORDER, EVENT_OR_SUBEVENT, EVENT),
|
||||
'Order': (ORDER, EVENT),
|
||||
}
|
||||
|
||||
|
||||
DataFieldInfo = namedtuple(
|
||||
'DataFieldInfo',
|
||||
field_names=('required_input', 'key', 'label', 'type', 'enum_opts', 'getter', 'deprecated'),
|
||||
defaults=[False]
|
||||
)
|
||||
|
||||
|
||||
def get_invoice_address_or_empty(order):
|
||||
try:
|
||||
return order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
return InvoiceAddress()
|
||||
|
||||
|
||||
def get_data_fields(event, for_model=None):
|
||||
"""
|
||||
Returns tuple of (required_input, key, label, type, enum_opts, getter)
|
||||
|
||||
Type is one of the Question types as defined in Question.TYPE_CHOICES.
|
||||
|
||||
The data type of the return value of `getter` depends on `type`:
|
||||
- TYPE_CHOICE_MULTIPLE: list of strings
|
||||
- TYPE_CHOICE: list, containing zero or one strings
|
||||
- TYPE_BOOLEAN: boolean
|
||||
- all other (including TYPE_NUMBER): string
|
||||
"""
|
||||
name_scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
name_headers = []
|
||||
if name_scheme and len(name_scheme["fields"]) > 1:
|
||||
for k, label, w in name_scheme["fields"]:
|
||||
name_headers.append(label)
|
||||
|
||||
src_fields = (
|
||||
[
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"attendee_name",
|
||||
_("Attendee name"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.attendee_name
|
||||
or (position.addon_to.attendee_name if position.addon_to else None),
|
||||
),
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"attendee_name_" + k,
|
||||
_("Attendee") + ": " + label,
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
partial(
|
||||
lambda k, position: (
|
||||
position.attendee_name_parts
|
||||
or (position.addon_to.attendee_name_parts if position.addon_to else {})
|
||||
or {}
|
||||
).get(k, ""),
|
||||
k,
|
||||
),
|
||||
deprecated=len(name_scheme["fields"]) == 1,
|
||||
)
|
||||
for k, label, w in name_scheme["fields"]
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"attendee_email",
|
||||
_("Attendee email"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: normalize_email(
|
||||
position.attendee_email
|
||||
or (position.addon_to.attendee_email if position.addon_to else None)
|
||||
),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"attendee_or_order_email",
|
||||
_("Attendee or order email"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: normalize_email(
|
||||
position.attendee_email
|
||||
or (position.addon_to.attendee_email if position.addon_to else None)
|
||||
or position.order.email
|
||||
),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"attendee_company",
|
||||
_("Attendee company"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.company or (position.addon_to.company if position.addon_to else None),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"attendee_street",
|
||||
_("Attendee address street"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.street or (position.addon_to.street if position.addon_to else None),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"attendee_zipcode",
|
||||
_("Attendee address ZIP code"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.zipcode or (position.addon_to.zipcode if position.addon_to else None),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"attendee_city",
|
||||
_("Attendee address city"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.city or (position.addon_to.city if position.addon_to else None),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"attendee_country",
|
||||
_("Attendee address country"),
|
||||
Question.TYPE_COUNTRYCODE,
|
||||
None,
|
||||
lambda position: str(
|
||||
position.country or (position.addon_to.attendee_name if position.addon_to else "")
|
||||
),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"invoice_address_company",
|
||||
_("Invoice address company"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: get_invoice_address_or_empty(order).company,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"invoice_address_name",
|
||||
_("Invoice address name"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: get_invoice_address_or_empty(order).name,
|
||||
),
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"invoice_address_name_" + k,
|
||||
_("Invoice address") + ": " + label,
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
partial(
|
||||
lambda k, order: (get_invoice_address_or_empty(order).name_parts or {}).get(
|
||||
k, ""
|
||||
),
|
||||
k,
|
||||
),
|
||||
deprecated=len(name_scheme["fields"]) == 1,
|
||||
)
|
||||
for k, label, w in name_scheme["fields"]
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"invoice_address_street",
|
||||
_("Invoice address street"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: get_invoice_address_or_empty(order).street,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"invoice_address_zipcode",
|
||||
_("Invoice address ZIP code"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: get_invoice_address_or_empty(order).zipcode,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"invoice_address_city",
|
||||
_("Invoice address city"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: get_invoice_address_or_empty(order).city,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"invoice_address_country",
|
||||
_("Invoice address country"),
|
||||
Question.TYPE_COUNTRYCODE,
|
||||
None,
|
||||
lambda order: str(get_invoice_address_or_empty(order).country),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"email",
|
||||
_("Order email"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: normalize_email(order.email),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"order_code",
|
||||
_("Order code"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: order.code,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"event_order_code",
|
||||
_("Event and order code"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: order.full_code,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"order_total",
|
||||
_("Order total"),
|
||||
Question.TYPE_NUMBER,
|
||||
None,
|
||||
lambda order: str(order.total),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"product",
|
||||
_("Product and variation name"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: str(
|
||||
str(position.item.internal_name or position.item.name)
|
||||
+ ((" – " + str(position.variation.value)) if position.variation else "")
|
||||
),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"product_id",
|
||||
_("Product ID"),
|
||||
Question.TYPE_NUMBER,
|
||||
None,
|
||||
lambda position: str(position.item.pk),
|
||||
),
|
||||
DataFieldInfo(
|
||||
EVENT,
|
||||
"event_slug",
|
||||
_("Event short form"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda event: str(event.slug),
|
||||
),
|
||||
DataFieldInfo(
|
||||
EVENT,
|
||||
"event_name",
|
||||
_("Event name"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda event: str(event.name),
|
||||
),
|
||||
DataFieldInfo(
|
||||
EVENT_OR_SUBEVENT,
|
||||
"event_date_from",
|
||||
_("Event start date"),
|
||||
Question.TYPE_DATETIME,
|
||||
None,
|
||||
lambda event_or_subevent: isoformat_or_none(event_or_subevent.date_from),
|
||||
),
|
||||
DataFieldInfo(
|
||||
EVENT_OR_SUBEVENT,
|
||||
"event_date_to",
|
||||
_("Event end date"),
|
||||
Question.TYPE_DATETIME,
|
||||
None,
|
||||
lambda event_or_subevent: isoformat_or_none(event_or_subevent.date_to),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"voucher_code",
|
||||
_("Voucher code"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.voucher.code if position.voucher_id else "",
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"ticket_id",
|
||||
_("Order code and position number"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.code,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"ticket_price",
|
||||
_("Ticket price"),
|
||||
Question.TYPE_NUMBER,
|
||||
None,
|
||||
lambda position: str(position.price),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"order_status",
|
||||
_("Order status"),
|
||||
Question.TYPE_CHOICE,
|
||||
Order.STATUS_CHOICE,
|
||||
lambda order: [order.status],
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"ticket_status",
|
||||
_("Ticket status"),
|
||||
Question.TYPE_CHOICE,
|
||||
Order.STATUS_CHOICE,
|
||||
lambda position: [Order.STATUS_CANCELED if position.canceled else position.order.status],
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"order_date",
|
||||
_("Order date and time"),
|
||||
Question.TYPE_DATETIME,
|
||||
None,
|
||||
lambda order: order.datetime.isoformat(),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"payment_date",
|
||||
_("Payment date and time"),
|
||||
Question.TYPE_DATETIME,
|
||||
None,
|
||||
get_payment_date,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"order_locale",
|
||||
_("Order language code"),
|
||||
Question.TYPE_CHOICE,
|
||||
[(lc, lc) for lc in event.settings.locales],
|
||||
lambda order: [order.locale],
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"position_id",
|
||||
_("Order position ID"),
|
||||
Question.TYPE_NUMBER,
|
||||
None,
|
||||
lambda op: str(op.pk),
|
||||
),
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"checkin_date_" + str(cl.pk),
|
||||
_("Check-in datetime on list {}").format(cl.name),
|
||||
Question.TYPE_DATETIME,
|
||||
None,
|
||||
partial(first_checkin_on_list, cl.pk),
|
||||
)
|
||||
for cl in event.checkin_lists.all()
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"question_" + q.identifier,
|
||||
_("Question: {name}").format(name=str(q.question)),
|
||||
q.type,
|
||||
get_enum_opts(q),
|
||||
partial(lambda qq, position: get_answer(position, qq.identifier), q),
|
||||
)
|
||||
for q in event.questions.filter(~Q(type=Question.TYPE_FILE)).prefetch_related("options")
|
||||
]
|
||||
)
|
||||
if not any(field_name == "given_name" for field_name, label, weight in name_scheme["fields"]):
|
||||
src_fields += [
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"attendee_name_given_name",
|
||||
_("Attendee") + ": " + _("Given name") + " (⚠️ auto-generated, not recommended)",
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: split_name_on_last_space(position.attendee_name, part=0),
|
||||
deprecated=True,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"invoice_address_name_given_name",
|
||||
_("Invoice address") + ": " + _("Given name") + " (⚠️ auto-generated, not recommended)",
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: split_name_on_last_space(get_invoice_address_or_empty(order).name, part=0),
|
||||
deprecated=True,
|
||||
),
|
||||
]
|
||||
|
||||
if not any(field_name == "family_name" for field_name, label, weight in name_scheme["fields"]):
|
||||
src_fields += [
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
"attendee_name_family_name",
|
||||
_("Attendee") + ": " + _("Family name") + " (⚠️ auto-generated, not recommended)",
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: split_name_on_last_space(position.attendee_name, part=1),
|
||||
deprecated=True,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
"invoice_address_name_family_name",
|
||||
_("Invoice address") + ": " + _("Family name") + " (⚠️ auto-generated, not recommended)",
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: split_name_on_last_space(get_invoice_address_or_empty(order).name, part=1),
|
||||
deprecated=True,
|
||||
),
|
||||
]
|
||||
|
||||
if for_model:
|
||||
available_inputs = AVAILABLE_MODELS[for_model]
|
||||
return [
|
||||
f for f in src_fields if f.required_input in available_inputs
|
||||
]
|
||||
else:
|
||||
return src_fields
|
||||
|
||||
|
||||
def get_enum_opts(q):
|
||||
if q.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
return [(opt.identifier, opt.answer) for opt in q.options.all()]
|
||||
else:
|
||||
return None
|
||||
123
src/pretix/base/datasync/utils.py
Normal file
123
src/pretix/base/datasync/utils.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from typing import List, Tuple
|
||||
|
||||
from pretix.base.datasync.datasync import SyncConfigError
|
||||
from pretix.base.models.datasync import (
|
||||
MODE_APPEND_LIST, MODE_OVERWRITE, MODE_SET_IF_EMPTY, MODE_SET_IF_NEW,
|
||||
)
|
||||
|
||||
|
||||
def assign_properties(
|
||||
new_values: List[Tuple[str, str, str]], old_values: dict, is_new, list_sep
|
||||
):
|
||||
"""
|
||||
Generates a dictionary mapping property keys to new values, handling conditional overwrites and list updates
|
||||
according to an update mode specified per property.
|
||||
|
||||
Supported update modes are:
|
||||
- `MODE_OVERWRITE`: Replaces the existing value with the new value.
|
||||
- `MODE_SET_IF_NEW`: Only sets the property if `is_new` is True.
|
||||
- `MODE_SET_IF_EMPTY`: Only sets the property if the field is empty or missing in old_values.
|
||||
- `MODE_APPEND_LIST`: Appends the new value to the list from old_values (or the empty list if missing),
|
||||
using `list_sep` as a separator.
|
||||
|
||||
:param new_values: List of tuples, where each tuple contains (field_name, new_value, update_mode).
|
||||
:param old_values: Dictionary, current property values in the external system.
|
||||
:param is_new: Boolean, whether the object will be newly created in the external system.
|
||||
:param list_sep: If string, used as a separator for MODE_APPEND_LIST. If None, native lists are used.
|
||||
:raises SyncConfigError: If an invalid update mode is specified.
|
||||
:returns: A dictionary containing the properties that need to be updated in the external system.
|
||||
"""
|
||||
|
||||
out = {}
|
||||
|
||||
for field_name, new_value, update_mode in new_values:
|
||||
if update_mode == MODE_OVERWRITE:
|
||||
out[field_name] = new_value
|
||||
continue
|
||||
elif update_mode == MODE_SET_IF_NEW and not is_new:
|
||||
continue
|
||||
if not new_value:
|
||||
continue
|
||||
|
||||
current_value = old_values.get(field_name, out.get(field_name, ""))
|
||||
if update_mode in (MODE_SET_IF_EMPTY, MODE_SET_IF_NEW):
|
||||
if not current_value:
|
||||
out[field_name] = new_value
|
||||
elif update_mode == MODE_APPEND_LIST:
|
||||
_add_to_list(out, field_name, current_value, new_value, list_sep)
|
||||
else:
|
||||
raise SyncConfigError(["Invalid update mode " + update_mode])
|
||||
return out
|
||||
|
||||
|
||||
def _add_to_list(out, field_name, current_value, new_item, list_sep):
|
||||
new_item = str(new_item)
|
||||
if list_sep is not None:
|
||||
new_item = new_item.replace(list_sep, "")
|
||||
current_value = current_value.split(list_sep) if current_value else []
|
||||
elif not isinstance(current_value, (list, tuple)):
|
||||
current_value = [str(current_value)]
|
||||
if new_item not in current_value:
|
||||
new_list = current_value + [new_item]
|
||||
if list_sep is not None:
|
||||
new_list = list_sep.join(new_list)
|
||||
out[field_name] = new_list
|
||||
|
||||
|
||||
def translate_property_mappings(property_mappings, checkin_list_map):
|
||||
"""
|
||||
To properly handle copied events, users of data fields as provided by get_data_fields need to register to the
|
||||
event_copy_data signal and translate all stored references to those fields using this method.
|
||||
|
||||
For example, if you store your mappings in a custom Django model with a ForeignKey to Event:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@receiver(signal=event_copy_data, dispatch_uid="my_sync_event_copy_data")
|
||||
def event_copy_data_receiver(sender, other, checkin_list_map, **kwargs):
|
||||
object_mappings = other.my_object_mappings.all()
|
||||
object_mapping_map = {}
|
||||
for om in object_mappings:
|
||||
om = copy.copy(om)
|
||||
object_mapping_map[om.pk] = om
|
||||
om.pk = None
|
||||
om.event = sender
|
||||
om.property_mappings = translate_property_mappings(om.property_mappings, checkin_list_map)
|
||||
om.save()
|
||||
|
||||
"""
|
||||
mappings = []
|
||||
|
||||
for mapping in property_mappings:
|
||||
pretix_field = mapping["pretix_field"]
|
||||
if pretix_field.startswith("checkin_date_"):
|
||||
old_id = int(pretix_field[len("checkin_date_"):])
|
||||
if old_id not in checkin_list_map:
|
||||
# old_id might not be in checkin_list_map, because copying of an event series only copies check-in
|
||||
# lists covering the whole series, not individual dates.
|
||||
pretix_field = "_invalid_" + pretix_field
|
||||
else:
|
||||
pretix_field = "checkin_date_%d" % checkin_list_map[old_id].pk
|
||||
mappings.append({**mapping, "pretix_field": pretix_field})
|
||||
return mappings
|
||||
@@ -68,6 +68,7 @@ from ...control.forms.filter import get_all_payment_providers
|
||||
from ...helpers import GroupConcat
|
||||
from ...helpers.iter import chunked_iterable
|
||||
from ...helpers.safe_openpyxl import remove_invalid_excel_chars
|
||||
from ...multidomain.urlreverse import build_absolute_uri
|
||||
from ..exporter import (
|
||||
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
|
||||
)
|
||||
@@ -287,6 +288,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('Email address verified'))
|
||||
headers.append(_('External customer ID'))
|
||||
headers.append(_('Payment providers'))
|
||||
headers.append(_('Order link'))
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_methods = self._get_all_payment_methods(qs)
|
||||
for id, vn in payment_methods:
|
||||
@@ -402,6 +404,13 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
if p and p != 'free'
|
||||
]))
|
||||
|
||||
row.append(
|
||||
build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
})
|
||||
)
|
||||
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_methods = self._get_all_payment_methods(qs)
|
||||
for id, vn in payment_methods:
|
||||
@@ -659,6 +668,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('External customer ID'),
|
||||
_('Check-in lists'),
|
||||
_('Payment providers'),
|
||||
_('Position order link')
|
||||
]
|
||||
|
||||
# get meta_data labels from first cached event
|
||||
@@ -803,6 +813,14 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
if p and p != 'free'
|
||||
]))
|
||||
|
||||
row.append(
|
||||
build_absolute_uri(order.event, 'presale:event.order.position', kwargs={
|
||||
'order': order.code,
|
||||
'secret': op.web_secret,
|
||||
'position': op.positionid
|
||||
})
|
||||
)
|
||||
|
||||
if has_subevents:
|
||||
if op.subevent:
|
||||
row += op.subevent.meta_data.values()
|
||||
|
||||
@@ -639,11 +639,16 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
))
|
||||
|
||||
if self.invoice.introductory_text:
|
||||
# While all intro fields are appended without any blank lines; we do want one before the optional intro
|
||||
# text. However, if there are no prior intro fields, adding an additional spacer will waste space.
|
||||
if story:
|
||||
story.append(Spacer(1, 5 * mm))
|
||||
|
||||
story.append(Paragraph(
|
||||
self._clean_text(self.invoice.introductory_text, tags=['br']),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
story.append(Spacer(1, 10 * mm))
|
||||
story.append(Spacer(1, 5 * mm))
|
||||
|
||||
return story
|
||||
|
||||
|
||||
35
src/pretix/base/management/commands/makemessages.py
Normal file
35
src/pretix/base/management/commands/makemessages.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import re
|
||||
|
||||
from django.core.management.commands import makemessages
|
||||
|
||||
|
||||
def is_valid_locale(locale):
|
||||
return re.match(r"^[a-z]+$", locale) or re.match(r"^[a-z]+_[A-Z0-9].*$", locale)
|
||||
|
||||
|
||||
makemessages.is_valid_locale = is_valid_locale
|
||||
|
||||
|
||||
class Command(makemessages.Command):
|
||||
pass
|
||||
@@ -38,6 +38,7 @@ import traceback
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import close_old_connections
|
||||
from django.dispatch.dispatcher import NO_RECEIVERS
|
||||
|
||||
from pretix.helpers.periodic import SKIPPED
|
||||
@@ -79,6 +80,8 @@ class Command(BaseCommand):
|
||||
self.stdout.write(f'INFO Running {name}…')
|
||||
t0 = time.time()
|
||||
try:
|
||||
# Check if the DB connection is still good, it might be closed if the previous task took too long.
|
||||
close_old_connections()
|
||||
r = receiver(signal=periodic_task, sender=self)
|
||||
except Exception as err:
|
||||
if isinstance(err, KeyboardInterrupt):
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 4.2.21 on 2025-06-27 13:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0283_taxrule_default_taxrule_backfill'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OrderSyncResult',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('sync_provider', models.CharField(max_length=128)),
|
||||
('mapping_id', models.IntegerField()),
|
||||
('external_object_type', models.CharField(max_length=128)),
|
||||
('external_id_field', models.CharField(max_length=128)),
|
||||
('id_value', models.CharField(max_length=128)),
|
||||
('external_link_href', models.CharField(max_length=255, null=True)),
|
||||
('external_link_display_name', models.CharField(max_length=255, null=True)),
|
||||
('transmitted', models.DateTimeField(auto_now_add=True)),
|
||||
('sync_info', models.JSONField()),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sync_results', to='pretixbase.order')),
|
||||
('order_position', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sync_results', to='pretixbase.orderposition')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['order', 'sync_provider'], name='pretixbase__order_i_3e3c84_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrderSyncQueue',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('sync_provider', models.CharField(max_length=128)),
|
||||
('triggered_by', models.CharField(max_length=128)),
|
||||
('triggered', models.DateTimeField(auto_now_add=True)),
|
||||
('failed_attempts', models.PositiveIntegerField(default=0)),
|
||||
('not_before', models.DateTimeField(db_index=True)),
|
||||
('need_manual_retry', models.CharField(null=True, max_length=20)),
|
||||
('in_flight', models.BooleanField(default=False)),
|
||||
('in_flight_since', models.DateTimeField(blank=True, null=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='queued_sync_jobs', to='pretixbase.event')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='queued_sync_jobs', to='pretixbase.order')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('triggered',),
|
||||
'unique_together': {('order', 'sync_provider', 'in_flight')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -111,6 +111,13 @@ class ImportColumn:
|
||||
"""
|
||||
return gettext_lazy('Keep empty')
|
||||
|
||||
@property
|
||||
def help_text(self):
|
||||
"""
|
||||
Additional description of the column
|
||||
"""
|
||||
return None
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ from pretix.base.signals import order_import_columns
|
||||
class EmailColumn(ImportColumn):
|
||||
identifier = 'email'
|
||||
verbose_name = gettext_lazy('Email address')
|
||||
order_level = True
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
@@ -67,9 +68,24 @@ class EmailColumn(ImportColumn):
|
||||
order.email = value
|
||||
|
||||
|
||||
class GroupingColumn(ImportColumn):
|
||||
identifier = 'grouping'
|
||||
verbose_name = gettext_lazy('Grouping')
|
||||
help_text = gettext_lazy(
|
||||
'Only applicable when "Import mode" is set to "Group multiple lines together...". Lines with the same grouping '
|
||||
'value will be put in the same order, but MUST be consecutive lines of the input file.'
|
||||
)
|
||||
order_level = True
|
||||
default_label = "---"
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class PhoneColumn(ImportColumn):
|
||||
identifier = 'phone'
|
||||
verbose_name = gettext_lazy('Phone number')
|
||||
order_level = True
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
@@ -94,6 +110,10 @@ class SubeventColumn(SubeventColumnMixin, ImportColumn):
|
||||
identifier = 'subevent'
|
||||
verbose_name = pgettext_lazy('subevents', 'Date')
|
||||
default_value = None
|
||||
help_text = pgettext_lazy(
|
||||
'subevents', 'The date can be specified through its full name, full date and time, or internal ID, provided '
|
||||
'only one date in the system matches the input.'
|
||||
)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
@@ -108,6 +128,7 @@ class ItemColumn(ImportColumn):
|
||||
identifier = 'item'
|
||||
verbose_name = gettext_lazy('Product')
|
||||
default_value = None
|
||||
help_text = gettext_lazy('The product can be specified by its internal ID, full name or internal name.')
|
||||
|
||||
@cached_property
|
||||
def items(self):
|
||||
@@ -137,6 +158,7 @@ class ItemColumn(ImportColumn):
|
||||
class Variation(ImportColumn):
|
||||
identifier = 'variation'
|
||||
verbose_name = gettext_lazy('Product variation')
|
||||
help_text = gettext_lazy('The variation can be specified by its internal ID or full name.')
|
||||
|
||||
@cached_property
|
||||
def items(self):
|
||||
@@ -170,6 +192,7 @@ class Variation(ImportColumn):
|
||||
|
||||
class InvoiceAddressCompany(ImportColumn):
|
||||
identifier = 'invoice_address_company'
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -181,6 +204,8 @@ class InvoiceAddressCompany(ImportColumn):
|
||||
|
||||
|
||||
class InvoiceAddressNamePart(ImportColumn):
|
||||
order_level = True
|
||||
|
||||
def __init__(self, event, key, label):
|
||||
self.key = key
|
||||
self.label = label
|
||||
@@ -200,6 +225,7 @@ class InvoiceAddressNamePart(ImportColumn):
|
||||
|
||||
class InvoiceAddressStreet(ImportColumn):
|
||||
identifier = 'invoice_address_street'
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -211,6 +237,7 @@ class InvoiceAddressStreet(ImportColumn):
|
||||
|
||||
class InvoiceAddressZip(ImportColumn):
|
||||
identifier = 'invoice_address_zipcode'
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -222,6 +249,7 @@ class InvoiceAddressZip(ImportColumn):
|
||||
|
||||
class InvoiceAddressCity(ImportColumn):
|
||||
identifier = 'invoice_address_city'
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -234,6 +262,8 @@ class InvoiceAddressCity(ImportColumn):
|
||||
class InvoiceAddressCountry(ImportColumn):
|
||||
identifier = 'invoice_address_country'
|
||||
default_value = None
|
||||
help_text = gettext_lazy('The country needs to be specified using a two-letter country code.')
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
@@ -257,6 +287,8 @@ class InvoiceAddressCountry(ImportColumn):
|
||||
|
||||
class InvoiceAddressState(ImportColumn):
|
||||
identifier = 'invoice_address_state'
|
||||
help_text = gettext_lazy('The state can be specified by its short form or full name.')
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -282,6 +314,7 @@ class InvoiceAddressState(ImportColumn):
|
||||
|
||||
class InvoiceAddressVATID(ImportColumn):
|
||||
identifier = 'invoice_address_vat_id'
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -293,6 +326,7 @@ class InvoiceAddressVATID(ImportColumn):
|
||||
|
||||
class InvoiceAddressReference(ImportColumn):
|
||||
identifier = 'invoice_address_internal_reference'
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -380,6 +414,7 @@ class AttendeeCity(ImportColumn):
|
||||
class AttendeeCountry(ImportColumn):
|
||||
identifier = 'attendee_country'
|
||||
default_value = None
|
||||
help_text = gettext_lazy('The country needs to be specified using a two-letter country code.')
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
@@ -403,6 +438,7 @@ class AttendeeCountry(ImportColumn):
|
||||
|
||||
class AttendeeState(ImportColumn):
|
||||
identifier = 'attendee_state'
|
||||
help_text = gettext_lazy('The state can be specified by its short form or full name.')
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -471,6 +507,7 @@ class Locale(ImportColumn):
|
||||
identifier = 'locale'
|
||||
verbose_name = gettext_lazy('Order locale')
|
||||
default_value = None
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
@@ -514,6 +551,7 @@ class ValidUntil(DatetimeColumnMixin, ImportColumn):
|
||||
class Expires(DatetimeColumnMixin, ImportColumn):
|
||||
identifier = 'expires'
|
||||
verbose_name = gettext_lazy('Expiry date')
|
||||
order_level = True
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
@@ -540,6 +578,8 @@ class Saleschannel(ImportColumn):
|
||||
verbose_name = gettext_lazy('Sales channel')
|
||||
default_value = None
|
||||
initial = 'static:web'
|
||||
help_text = gettext_lazy('The sales channel can be specified by it\'s internal identifier or its full name.')
|
||||
order_level = True
|
||||
|
||||
@cached_property
|
||||
def channels(self):
|
||||
@@ -568,6 +608,7 @@ class Saleschannel(ImportColumn):
|
||||
class SeatColumn(ImportColumn):
|
||||
identifier = 'seat'
|
||||
verbose_name = gettext_lazy('Seat ID')
|
||||
help_text = gettext_lazy('The seat needs to be specified by its internal ID.')
|
||||
|
||||
def __init__(self, *args):
|
||||
self._cached = set()
|
||||
@@ -599,7 +640,8 @@ class SeatColumn(ImportColumn):
|
||||
|
||||
class Comment(ImportColumn):
|
||||
identifier = 'comment'
|
||||
verbose_name = gettext_lazy('Comment')
|
||||
verbose_name = gettext_lazy('Order comment')
|
||||
order_level = True
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
order.comment = value or ''
|
||||
@@ -608,6 +650,7 @@ class Comment(ImportColumn):
|
||||
class CheckinAttentionColumn(BooleanColumnMixin, ImportColumn):
|
||||
identifier = 'checkin_attention'
|
||||
verbose_name = gettext_lazy('Requires special attention')
|
||||
order_level = True
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
order.checkin_attention = value
|
||||
@@ -616,6 +659,7 @@ class CheckinAttentionColumn(BooleanColumnMixin, ImportColumn):
|
||||
class CheckinTextColumn(ImportColumn):
|
||||
identifier = 'checkin_text'
|
||||
verbose_name = gettext_lazy('Check-in text')
|
||||
order_level = True
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
order.checkin_text = value
|
||||
@@ -696,6 +740,7 @@ class QuestionColumn(ImportColumn):
|
||||
class CustomerColumn(ImportColumn):
|
||||
identifier = 'customer'
|
||||
verbose_name = gettext_lazy('Customer')
|
||||
order_level = True
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
@@ -720,6 +765,7 @@ def get_order_import_columns(event):
|
||||
if event.has_subevents:
|
||||
default.append(SubeventColumn(event))
|
||||
default += [
|
||||
GroupingColumn(event),
|
||||
EmailColumn(event),
|
||||
PhoneColumn(event),
|
||||
ItemColumn(event),
|
||||
|
||||
149
src/pretix/base/models/datasync.py
Normal file
149
src/pretix/base/models/datasync.py
Normal file
@@ -0,0 +1,149 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import logging
|
||||
from functools import cached_property
|
||||
|
||||
from django.db import IntegrityError, models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from pretix.base.models import Event, Order, OrderPosition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MODE_OVERWRITE = "overwrite"
|
||||
MODE_SET_IF_NEW = "if_new"
|
||||
MODE_SET_IF_EMPTY = "if_empty"
|
||||
MODE_APPEND_LIST = "append"
|
||||
|
||||
|
||||
class OrderSyncQueue(models.Model):
|
||||
order = models.ForeignKey(
|
||||
Order, on_delete=models.CASCADE, related_name="queued_sync_jobs"
|
||||
)
|
||||
event = models.ForeignKey(
|
||||
Event, on_delete=models.CASCADE, related_name="queued_sync_jobs"
|
||||
)
|
||||
sync_provider = models.CharField(blank=False, null=False, max_length=128)
|
||||
triggered_by = models.CharField(blank=False, null=False, max_length=128)
|
||||
triggered = models.DateTimeField(blank=False, null=False, auto_now_add=True)
|
||||
failed_attempts = models.PositiveIntegerField(default=0)
|
||||
not_before = models.DateTimeField(blank=False, null=False, db_index=True)
|
||||
need_manual_retry = models.CharField(blank=True, null=True, max_length=20, choices=[
|
||||
('exceeded', _('Temporary error, auto-retry limit exceeded')),
|
||||
('permanent', _('Provider reported a permanent error')),
|
||||
('config', _('Misconfiguration, please check provider settings')),
|
||||
('internal', _('System error, needs manual intervention')),
|
||||
('timeout', _('System error, needs manual intervention')),
|
||||
])
|
||||
in_flight = models.BooleanField(default=False)
|
||||
in_flight_since = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = (("order", "sync_provider", "in_flight"),)
|
||||
ordering = ("triggered",)
|
||||
|
||||
@cached_property
|
||||
def _provider_class_info(self):
|
||||
from pretix.base.datasync.datasync import datasync_providers
|
||||
return datasync_providers.get(identifier=self.sync_provider)
|
||||
|
||||
@property
|
||||
def provider_class(self):
|
||||
return self._provider_class_info[0]
|
||||
|
||||
@property
|
||||
def provider_display_name(self):
|
||||
return self.provider_class.display_name
|
||||
|
||||
@property
|
||||
def is_provider_active(self):
|
||||
return self._provider_class_info[1]
|
||||
|
||||
@property
|
||||
def max_retry_attempts(self):
|
||||
return self.provider_class.max_attempts
|
||||
|
||||
def set_sync_error(self, failure_mode, messages, full_message):
|
||||
logger.exception(
|
||||
f"Could not sync order {self.order.code} to {type(self).__name__} ({failure_mode})"
|
||||
)
|
||||
self.order.log_action(f"pretix.event.order.data_sync.failed.{failure_mode}", {
|
||||
"provider": self.sync_provider,
|
||||
"error": messages,
|
||||
"full_message": full_message,
|
||||
})
|
||||
self.need_manual_retry = failure_mode
|
||||
self.clear_in_flight()
|
||||
|
||||
def clear_in_flight(self):
|
||||
self.in_flight = False
|
||||
self.in_flight_since = None
|
||||
try:
|
||||
self.save()
|
||||
except IntegrityError:
|
||||
# if setting in_flight=False fails due to UNIQUE constraint, just delete the current instance
|
||||
self.delete()
|
||||
|
||||
|
||||
class OrderSyncResult(models.Model):
|
||||
order = models.ForeignKey(
|
||||
Order, on_delete=models.CASCADE, related_name="sync_results"
|
||||
)
|
||||
sync_provider = models.CharField(blank=False, null=False, max_length=128)
|
||||
order_position = models.ForeignKey(
|
||||
OrderPosition, on_delete=models.CASCADE, related_name="sync_results", blank=True, null=True,
|
||||
)
|
||||
mapping_id = models.IntegerField(blank=False, null=False)
|
||||
external_object_type = models.CharField(blank=False, null=False, max_length=128)
|
||||
external_id_field = models.CharField(blank=False, null=False, max_length=128)
|
||||
id_value = models.CharField(blank=False, null=False, max_length=128)
|
||||
external_link_href = models.CharField(blank=True, null=True, max_length=255)
|
||||
external_link_display_name = models.CharField(blank=True, null=True, max_length=255)
|
||||
transmitted = models.DateTimeField(blank=False, null=False, auto_now_add=True)
|
||||
sync_info = models.JSONField()
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=("order", "sync_provider")),
|
||||
]
|
||||
|
||||
def external_link_html(self):
|
||||
if not self.external_link_display_name:
|
||||
return None
|
||||
|
||||
from pretix.base.datasync.datasync import datasync_providers
|
||||
prov, meta = datasync_providers.get(identifier=self.sync_provider)
|
||||
if prov:
|
||||
return prov.get_external_link_html(self.order.event, self.external_link_href, self.external_link_display_name)
|
||||
|
||||
def to_result_dict(self):
|
||||
return {
|
||||
"position": self.order_position_id,
|
||||
"object_type": self.external_object_type,
|
||||
"external_id_field": self.external_id_field,
|
||||
"id_value": self.id_value,
|
||||
"external_link_href": self.external_link_href,
|
||||
"external_link_display_name": self.external_link_display_name,
|
||||
**self.sync_info,
|
||||
}
|
||||
@@ -1821,7 +1821,7 @@ class OrderPayment(models.Model):
|
||||
|
||||
def fail(self, info=None, user=None, auth=None, log_data=None, send_mail=True):
|
||||
"""
|
||||
Marks the order as failed and sets info to ``info``, but only if the order is in ``created`` or ``pending``
|
||||
Marks the order as failed and sets info to ``info``, but only if the order is in ``created``, ``pending`` or ``canceled``
|
||||
state. This is equivalent to setting ``state`` to ``OrderPayment.PAYMENT_STATE_FAILED`` and logging a failure,
|
||||
but it adds strong database locking since we do not want to report a failure for an order that has just
|
||||
been marked as paid.
|
||||
@@ -1829,7 +1829,11 @@ class OrderPayment(models.Model):
|
||||
"""
|
||||
with transaction.atomic():
|
||||
locked_instance = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=self.pk)
|
||||
if locked_instance.state not in (OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING):
|
||||
if locked_instance.state in (
|
||||
OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
OrderPayment.PAYMENT_STATE_FAILED,
|
||||
OrderPayment.PAYMENT_STATE_REFUNDED
|
||||
):
|
||||
# Race condition detected, this payment is already confirmed
|
||||
logger.info('Failed payment {} but ignored due to likely race condition.'.format(
|
||||
self.full_id,
|
||||
@@ -3310,6 +3314,24 @@ class InvoiceAddress(models.Model):
|
||||
kwargs['update_fields'] = {'name_cached', 'name_parts'}.union(kwargs['update_fields'])
|
||||
super().save(**kwargs)
|
||||
|
||||
def clear(self, except_name=False):
|
||||
self.is_business = False
|
||||
if not except_name:
|
||||
self.name_cached = ""
|
||||
self.name_parts = {}
|
||||
self.company = ""
|
||||
self.street = ""
|
||||
self.zipcode = ""
|
||||
self.city = ""
|
||||
self.country_old = ""
|
||||
self.country = ""
|
||||
self.state = ""
|
||||
self.vat_id = ""
|
||||
self.vat_id_validated = False
|
||||
self.custom_field = None
|
||||
self.internal_reference = ""
|
||||
self.beneficiary = ""
|
||||
|
||||
def describe(self):
|
||||
parts = [
|
||||
self.company,
|
||||
|
||||
@@ -159,8 +159,17 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": _("123.45 EUR"),
|
||||
"evaluate": lambda op, order, event: money_filter(op.price, event.currency)
|
||||
}),
|
||||
("price_with_bundled", {
|
||||
"label": _("Price including bundled products"),
|
||||
"editor_sample": _("123.45 EUR"),
|
||||
"evaluate": lambda op, order, event: money_filter(op.price + sum(
|
||||
p.price
|
||||
for p in op.addons.all()
|
||||
if not p.canceled and p.is_bundled
|
||||
), event.currency)
|
||||
}),
|
||||
("price_with_addons", {
|
||||
"label": _("Price including add-ons"),
|
||||
"label": _("Price including add-ons and bundled products"),
|
||||
"editor_sample": _("123.45 EUR"),
|
||||
"evaluate": lambda op, order, event: money_filter(op.price + sum(
|
||||
p.price
|
||||
|
||||
@@ -28,6 +28,9 @@ from dateutil import parser
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
@@ -206,14 +209,27 @@ class RelativeDateTimeWidget(forms.MultiWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.status_choices = kwargs.pop('status_choices')
|
||||
base_choices = kwargs.pop('base_choices')
|
||||
|
||||
def placeholder_datetime_format():
|
||||
df = get_format('DATETIME_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
|
||||
def placeholder_time_format():
|
||||
tf = get_format('TIME_INPUT_FORMATS')[0]
|
||||
return datetime.time(8, 30, 0).strftime(tf)
|
||||
|
||||
widgets = reldatetimeparts(
|
||||
status=forms.RadioSelect(choices=self.status_choices),
|
||||
absolute=forms.DateTimeInput(
|
||||
attrs={'class': 'datetimepicker'}
|
||||
attrs={'placeholder': lazy(placeholder_datetime_format, str), 'class': 'datetimepicker'}
|
||||
),
|
||||
rel_days_number=forms.NumberInput(),
|
||||
rel_mins_relationto=forms.Select(choices=base_choices),
|
||||
rel_days_timeofday=forms.TimeInput(attrs={'placeholder': _('Time'), 'class': 'timepickerfield'}),
|
||||
rel_days_timeofday=forms.TimeInput(
|
||||
attrs={'placeholder': lazy(placeholder_time_format, str), 'class': 'timepickerfield'}
|
||||
),
|
||||
rel_mins_number=forms.NumberInput(),
|
||||
rel_days_relationto=forms.Select(choices=base_choices),
|
||||
rel_mins_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
|
||||
|
||||
85
src/pretix/base/services/datasync.py
Normal file
85
src/pretix/base/services/datasync.py
Normal file
@@ -0,0 +1,85 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from itertools import groupby
|
||||
|
||||
from django.db.models import F, Window
|
||||
from django.db.models.functions import RowNumber
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scope, scopes_disabled
|
||||
|
||||
from pretix.base.datasync.datasync import datasync_providers
|
||||
from pretix.base.models.datasync import OrderSyncQueue
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(periodic_task, dispatch_uid="data_sync_periodic_sync_all")
|
||||
def periodic_sync_all(sender, **kwargs):
|
||||
sync_all.apply_async()
|
||||
|
||||
|
||||
@receiver(periodic_task, dispatch_uid="data_sync_periodic_reset_in_flight")
|
||||
def periodic_reset_in_flight(sender, **kwargs):
|
||||
for sq in OrderSyncQueue.objects.filter(
|
||||
in_flight=True,
|
||||
in_flight_since__lt=now() - timedelta(minutes=20),
|
||||
):
|
||||
sq.set_sync_error('timeout', [], 'Timeout')
|
||||
|
||||
|
||||
@app.task()
|
||||
def sync_all():
|
||||
with scopes_disabled():
|
||||
queue = (
|
||||
OrderSyncQueue.objects
|
||||
.filter(
|
||||
in_flight=False,
|
||||
not_before__lt=now(),
|
||||
need_manual_retry__isnull=True,
|
||||
)
|
||||
.order_by(Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[F("event_id")],
|
||||
order_by="not_before",
|
||||
))
|
||||
.prefetch_related("event")
|
||||
[:1000]
|
||||
)
|
||||
grouped = groupby(sorted(queue, key=lambda q: (q.sync_provider, q.event.pk)), lambda q: (q.sync_provider, q.event))
|
||||
for (target, event), queued_orders in grouped:
|
||||
target_cls, meta = datasync_providers.get(identifier=target, active_in=event)
|
||||
|
||||
if not target_cls:
|
||||
# sync plugin not found (plugin deactivated or uninstalled) -> drop outstanding jobs
|
||||
num_deleted, _ = OrderSyncQueue.objects.filter(pk__in=[sq.pk for sq in queued_orders]).delete()
|
||||
logger.info("Deleted %d queue entries from %r because plugin %s inactive", num_deleted, event, target)
|
||||
continue
|
||||
|
||||
with scope(organizer=event.organizer):
|
||||
with target_cls(event=event) as p:
|
||||
p.sync_queued_orders(queued_orders)
|
||||
@@ -89,6 +89,9 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
||||
_('Orders cannot have more than %(max)s positions.') % {'max': django_settings.PRETIX_MAX_ORDER_SIZE}
|
||||
)
|
||||
|
||||
used_groupers = set()
|
||||
current_grouper = []
|
||||
current_order_level_data = {}
|
||||
orders = []
|
||||
order = None
|
||||
|
||||
@@ -97,7 +100,28 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
||||
lock_seats = []
|
||||
for i, record in enumerate(data):
|
||||
try:
|
||||
if order is None or settings['orders'] == 'many':
|
||||
create_new_order = (
|
||||
order is None or
|
||||
settings['orders'] == 'many' or
|
||||
(settings['orders'] == 'mixed' and record["grouping"] != current_grouper)
|
||||
)
|
||||
|
||||
if create_new_order:
|
||||
if settings['orders'] == 'mixed':
|
||||
if record["grouping"] in used_groupers:
|
||||
raise DataImportError(
|
||||
_('The grouping "%(value)s" occurs on non-consecutive lines (seen again on line %(row)s).') % {
|
||||
"value": record["grouping"],
|
||||
"row": i + 1,
|
||||
}
|
||||
)
|
||||
current_grouper = record["grouping"]
|
||||
used_groupers.add(current_grouper)
|
||||
|
||||
current_order_level_data = {
|
||||
c.identifier: record.get(c.identifier)
|
||||
for c in cols if getattr(c, "order_level", False)
|
||||
}
|
||||
order = Order(
|
||||
event=event,
|
||||
testmode=settings['testmode'],
|
||||
@@ -108,6 +132,12 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
||||
order._address.name_parts = {'_scheme': event.settings.name_scheme}
|
||||
orders.append(order)
|
||||
|
||||
if settings['orders'] == 'mixed' and len(order._positions) >= django_settings.PRETIX_MAX_ORDER_SIZE:
|
||||
raise DataImportError(
|
||||
_('Orders cannot have more than %(max)s positions.') % {
|
||||
'max': django_settings.PRETIX_MAX_ORDER_SIZE}
|
||||
)
|
||||
|
||||
position = OrderPosition(positionid=len(order._positions) + 1)
|
||||
position.attendee_name_parts = {'_scheme': event.settings.name_scheme}
|
||||
position.meta_info = {}
|
||||
@@ -115,13 +145,24 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
||||
position.assign_pseudonymization_id()
|
||||
|
||||
for c in cols:
|
||||
c.assign(record.get(c.identifier), order, position, order._address)
|
||||
value = record.get(c.identifier)
|
||||
if getattr(c, "order_level", False) and value != current_order_level_data.get(c.identifier):
|
||||
raise DataImportError(
|
||||
_('Inconsistent data in row {row}: Column {col} contains value "{val_line}", but '
|
||||
'for this order, the value has already been set to "{val_order}".').format(
|
||||
row=i + 1,
|
||||
col=c.verbose_name,
|
||||
val_line=value,
|
||||
val_order=current_order_level_data.get(c.identifier) or "",
|
||||
)
|
||||
)
|
||||
c.assign(value, order, position, order._address)
|
||||
|
||||
if position.seat is not None:
|
||||
lock_seats.append((order.sales_channel, position.seat))
|
||||
except (ValidationError, ImportError) as e:
|
||||
raise DataImportError(
|
||||
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
|
||||
_('Invalid data in row {row}: {message}').format(row=i + 1, message=str(e))
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -257,8 +257,14 @@ class Registry:
|
||||
When a new entry is registered, all accessor functions are called with the new entry as parameter.
|
||||
Their return value is stored as the metadata value for that key.
|
||||
"""
|
||||
self.registered_entries = dict()
|
||||
self.keys = keys
|
||||
self.clear()
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
Removes all entries from the registry.
|
||||
"""
|
||||
self.registered_entries = dict()
|
||||
self.by_key = {key: {} for key in self.keys.keys()}
|
||||
|
||||
def register(self, *objs):
|
||||
@@ -333,6 +339,23 @@ class EventPluginRegistry(Registry):
|
||||
def __init__(self, keys):
|
||||
super().__init__({"plugin": lambda o: get_defining_app(o), **keys})
|
||||
|
||||
def filter(self, active_in=None, **kwargs):
|
||||
result = super().filter(**kwargs)
|
||||
if active_in is not None:
|
||||
result = (
|
||||
(entry, meta)
|
||||
for entry, meta in result
|
||||
if is_app_active(active_in, meta['plugin'])
|
||||
)
|
||||
return result
|
||||
|
||||
def get(self, active_in=None, **kwargs):
|
||||
item, meta = super().get(**kwargs)
|
||||
if meta and active_in is not None:
|
||||
if not is_app_active(active_in, meta['plugin']):
|
||||
return None, None
|
||||
return item, meta
|
||||
|
||||
|
||||
event_live_issues = EventPluginSignal()
|
||||
"""
|
||||
|
||||
@@ -60,11 +60,17 @@
|
||||
</td>
|
||||
<td>
|
||||
{{ event.name }}
|
||||
{% if not event.has_subevents and event.settings.show_dates_on_frontpage %}
|
||||
<br>
|
||||
{{ event.get_date_range_display }}
|
||||
{% if event.settings.show_times %}
|
||||
{{ event.date_from|date:"TIME_FORMAT" }}
|
||||
{% if not event.has_subevents %}
|
||||
{% if event.settings.show_dates_on_frontpage %}
|
||||
<br>
|
||||
{{ event.get_date_range_display }}
|
||||
{% if event.settings.show_times %}
|
||||
{{ event.date_from|date:"TIME_FORMAT" }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if event.location %}
|
||||
<br>
|
||||
{{ event.location|oneline }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -42,3 +42,4 @@ class PretixControlConfig(AppConfig):
|
||||
def ready(self):
|
||||
from .views import dashboards # noqa
|
||||
from . import logdisplay # noqa
|
||||
from .views import datasync # noqa
|
||||
|
||||
@@ -215,3 +215,9 @@ class CheckinListSimulatorForm(forms.Form):
|
||||
)
|
||||
self.fields['gate'].widget.choices = self.fields['gate'].choices
|
||||
self.fields['gate'].label = _('Gate')
|
||||
|
||||
|
||||
class CheckinResetForm(forms.Form):
|
||||
ok = forms.BooleanField(
|
||||
label=_("I am sure that the check-in state of the entire event should be reset.")
|
||||
)
|
||||
|
||||
@@ -113,7 +113,6 @@ class EventWizardFoundationForm(forms.Form):
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:organizers.select2') + '?can_create=1',
|
||||
'data-placeholder': _('Organizer')
|
||||
}
|
||||
),
|
||||
empty_label=None,
|
||||
@@ -984,7 +983,10 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
|
||||
|
||||
mail_bcc = forms.CharField(
|
||||
label=_("Bcc address"),
|
||||
help_text=_("All emails will be sent to this address as a Bcc copy"),
|
||||
help_text=' '.join([
|
||||
str(_("All emails will be sent to this address as a Bcc copy.")),
|
||||
str(_("You can specify multiple recipients separated by commas.")),
|
||||
]),
|
||||
validators=[multimail_validate],
|
||||
required=False,
|
||||
max_length=255
|
||||
|
||||
@@ -1246,9 +1246,7 @@ class SubEventFilterForm(FilterForm):
|
||||
)
|
||||
query = forms.CharField(
|
||||
label=_('Event name'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _('Event name'),
|
||||
}),
|
||||
widget=forms.TextInput(),
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -1693,9 +1691,7 @@ class EventFilterForm(FilterForm):
|
||||
)
|
||||
query = forms.CharField(
|
||||
label=_('Event name'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _('Event name'),
|
||||
}),
|
||||
widget=forms.TextInput(),
|
||||
required=False
|
||||
)
|
||||
date_from = forms.DateField(
|
||||
@@ -2448,7 +2444,7 @@ class CheckinFilterForm(FilterForm):
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Check-in list'),
|
||||
'data-placeholder': _('All check-in lists'),
|
||||
}
|
||||
)
|
||||
self.fields['checkin_list'].widget.choices = self.fields['checkin_list'].choices
|
||||
|
||||
@@ -47,9 +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.translation import (
|
||||
gettext as __, gettext_lazy as _, pgettext_lazy,
|
||||
)
|
||||
from django.utils.translation import gettext as __, gettext_lazy as _
|
||||
from django_scopes.forms import (
|
||||
SafeModelChoiceField, SafeModelMultipleChoiceField,
|
||||
)
|
||||
@@ -330,7 +328,6 @@ class QuotaForm(I18nModelForm):
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'Date')
|
||||
}
|
||||
)
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
@@ -352,6 +349,9 @@ class QuotaForm(I18nModelForm):
|
||||
field_classes = {
|
||||
'subevent': SafeModelChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'size': forms.NumberInput(attrs={'placeholder': _('Unlimited')})
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
creating = not self.instance.pk
|
||||
|
||||
123
src/pretix/control/forms/mapping.py
Normal file
123
src/pretix/control/forms/mapping.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django import forms
|
||||
from django.forms import formset_factory
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import Question
|
||||
from pretix.base.models.datasync import (
|
||||
MODE_APPEND_LIST, MODE_OVERWRITE, MODE_SET_IF_EMPTY, MODE_SET_IF_NEW,
|
||||
)
|
||||
|
||||
|
||||
class PropertyMappingForm(forms.Form):
|
||||
pretix_field = forms.CharField()
|
||||
external_field = forms.CharField()
|
||||
value_map = forms.CharField(required=False)
|
||||
overwrite = forms.ChoiceField(
|
||||
choices=[
|
||||
(MODE_OVERWRITE, _("Overwrite")),
|
||||
(MODE_SET_IF_NEW, _("Fill if new")),
|
||||
(MODE_SET_IF_EMPTY, _("Fill if empty")),
|
||||
(MODE_APPEND_LIST, _("Add to list")),
|
||||
]
|
||||
)
|
||||
|
||||
def __init__(self, pretix_fields, external_fields_id, available_modes, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["pretix_field"] = forms.ChoiceField(
|
||||
label=_("pretix field"),
|
||||
choices=pretix_fields_choices(pretix_fields, kwargs.get("initial", {}).get("pretix_field")),
|
||||
required=False,
|
||||
)
|
||||
if external_fields_id:
|
||||
self.fields["external_field"] = forms.ChoiceField(
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"data-model-select2": "json_script",
|
||||
"data-select2-src": "#" + external_fields_id,
|
||||
},
|
||||
),
|
||||
)
|
||||
self.fields["external_field"].choices = [
|
||||
(self["external_field"].value(), self["external_field"].value()),
|
||||
]
|
||||
self.fields["overwrite"].choices = [
|
||||
(key, label) for (key, label) in self.fields["overwrite"].choices if key in available_modes
|
||||
]
|
||||
|
||||
|
||||
class PropertyMappingFormSet(formset_factory(
|
||||
PropertyMappingForm,
|
||||
can_order=True,
|
||||
can_delete=True,
|
||||
extra=0,
|
||||
)):
|
||||
template_name = "pretixcontrol/datasync/property_mappings_formset.html"
|
||||
|
||||
def __init__(self, pretix_fields, external_fields, available_modes, prefix, *args, **kwargs):
|
||||
super().__init__(
|
||||
form_kwargs={
|
||||
"pretix_fields": pretix_fields,
|
||||
"external_fields_id": prefix + "external-fields" if external_fields else None,
|
||||
"available_modes": available_modes,
|
||||
},
|
||||
prefix=prefix,
|
||||
*args, **kwargs)
|
||||
self.external_fields = external_fields
|
||||
|
||||
def get_context(self):
|
||||
ctx = super().get_context()
|
||||
ctx["external_fields"] = self.external_fields
|
||||
ctx["external_fields_id"] = self.prefix + "external-fields"
|
||||
return ctx
|
||||
|
||||
def to_property_mappings_list(self):
|
||||
"""
|
||||
Returns a property mapping configuration as a JSON-serializable list of dictionaries.
|
||||
|
||||
Each entry specifies how to transfer data from one pretix field to one field in the external system:
|
||||
|
||||
- `pretix_field`: Name of a pretix data source field as declared in `pretix.base.datasync.sourcefields.get_data_fields`.
|
||||
- `external_field`: Name of the target field in the external system. Implementation-defined by the sync provider.
|
||||
- `value_map`: Dictionary mapping pretix value to external value. Only used for enumeration-type fields.
|
||||
- `overwrite`: Mode of operation if the object already exists in the target system.
|
||||
|
||||
- `MODE_OVERWRITE` (`"overwrite"`) to always overwrite existing value.
|
||||
- `MODE_SET_IF_NEW` (`"if_new"`) to only set the value if object does not exist in target system yet.
|
||||
- `MODE_SET_IF_EMPTY` (`"if_empty"`) to only set the value if object does not exist in target system,
|
||||
or the field is currently empty in target system.
|
||||
- `MODE_APPEND_LIST` (`"append"`) if the field is an array or a multi-select: add the value to the list.
|
||||
"""
|
||||
mappings = [f.cleaned_data for f in self.ordered_forms]
|
||||
return mappings
|
||||
|
||||
|
||||
QUESTION_TYPE_LABELS = dict(Question.TYPE_CHOICES)
|
||||
|
||||
|
||||
def pretix_fields_choices(pretix_fields, initial_choice):
|
||||
return [
|
||||
(f.key, f.label + " [" + QUESTION_TYPE_LABELS[f.type] + "]")
|
||||
for f in pretix_fields
|
||||
if not f.deprecated or f.key == initial_choice
|
||||
]
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.modelimport_orders import get_order_import_columns
|
||||
@@ -62,7 +63,8 @@ class ProcessForm(forms.Form):
|
||||
choices=choices,
|
||||
widget=forms.Select(
|
||||
attrs={'data-static': 'true'}
|
||||
)
|
||||
),
|
||||
help_text=c.help_text,
|
||||
)
|
||||
|
||||
def get_columns(self):
|
||||
@@ -75,14 +77,17 @@ class OrdersProcessForm(ProcessForm):
|
||||
choices=(
|
||||
('many', _('Create a separate order for each line')),
|
||||
('one', _('Create one order with one position per line')),
|
||||
)
|
||||
('mixed', _('Group multiple lines together into the same order based on a grouping column')),
|
||||
),
|
||||
widget=forms.RadioSelect,
|
||||
)
|
||||
status = forms.ChoiceField(
|
||||
label=_('Order status'),
|
||||
choices=(
|
||||
('paid', _('Create orders as fully paid')),
|
||||
('pending', _('Create orders as pending and still require payment')),
|
||||
)
|
||||
),
|
||||
widget=forms.RadioSelect,
|
||||
)
|
||||
testmode = forms.BooleanField(
|
||||
label=_('Create orders as test mode orders'),
|
||||
@@ -99,6 +104,17 @@ class OrdersProcessForm(ProcessForm):
|
||||
def get_columns(self):
|
||||
return get_order_import_columns(self.event)
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
|
||||
grouping = data.get("grouping") and data.get("grouping") != "empty"
|
||||
if data.get("orders") != "mixed" and grouping:
|
||||
raise ValidationError({"grouping": [_("A grouping cannot be specified for this import mode.")]})
|
||||
if data.get("orders") == "mixed" and not grouping:
|
||||
raise ValidationError({"grouping": [_("A grouping needs to be specified for this import mode.")]})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class VouchersProcessForm(ProcessForm):
|
||||
|
||||
|
||||
@@ -409,7 +409,6 @@ class OrderPositionAddForm(forms.Form):
|
||||
'event': order.event.slug,
|
||||
'organizer': order.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'Date')
|
||||
}
|
||||
)
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
@@ -705,7 +704,6 @@ class OrderContactForm(forms.ModelForm):
|
||||
'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
|
||||
'organizer': self.instance.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Customer')
|
||||
}
|
||||
)
|
||||
self.fields['customer'].widget.choices = self.fields['customer'].choices
|
||||
|
||||
@@ -581,7 +581,10 @@ class MailSettingsForm(SettingsForm):
|
||||
|
||||
mail_bcc = forms.CharField(
|
||||
label=_("Bcc address"),
|
||||
help_text=_("All emails will be sent to this address as a Bcc copy"),
|
||||
help_text=''.join([
|
||||
str(_("All emails will be sent to this address as a Bcc copy.")),
|
||||
str(_("You can specify multiple recipients separated by commas.")),
|
||||
]),
|
||||
validators=[multimail_validate],
|
||||
required=False,
|
||||
max_length=255
|
||||
@@ -778,7 +781,6 @@ class GiftCardUpdateForm(forms.ModelForm):
|
||||
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
|
||||
'organizer': organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Ticket')
|
||||
}
|
||||
)
|
||||
self.fields['owner_ticket'].widget.choices = self.fields['owner_ticket'].choices
|
||||
@@ -814,7 +816,6 @@ class ReusableMediumUpdateForm(forms.ModelForm):
|
||||
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
|
||||
'organizer': organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Ticket')
|
||||
}
|
||||
)
|
||||
self.fields['linked_orderposition'].widget.choices = self.fields['linked_orderposition'].choices
|
||||
@@ -827,7 +828,6 @@ class ReusableMediumUpdateForm(forms.ModelForm):
|
||||
'data-select2-url': reverse('control:organizer.giftcards.select2', kwargs={
|
||||
'organizer': organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Gift card')
|
||||
}
|
||||
)
|
||||
self.fields['linked_giftcard'].widget.choices = self.fields['linked_giftcard'].choices
|
||||
@@ -841,7 +841,6 @@ class ReusableMediumUpdateForm(forms.ModelForm):
|
||||
'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
|
||||
'organizer': organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Customer')
|
||||
}
|
||||
)
|
||||
self.fields['customer'].widget.choices = self.fields['customer'].choices
|
||||
|
||||
@@ -133,16 +133,12 @@ class SubEventBulkEditForm(I18nModelForm):
|
||||
# i18n fields
|
||||
if k in self.mixed_values:
|
||||
self.fields[k].widget.attrs['placeholder'] = '[{}]'.format(_('Selection contains various values'))
|
||||
else:
|
||||
self.fields[k].widget.attrs['placeholder'] = ''
|
||||
self.fields[k].one_required = False
|
||||
|
||||
for k in ('geo_lat', 'geo_lon', 'comment'):
|
||||
# scalar fields
|
||||
if k in self.mixed_values:
|
||||
self.fields[k].widget.attrs['placeholder'] = '[{}]'.format(_('Selection contains various values'))
|
||||
else:
|
||||
self.fields[k].widget.attrs['placeholder'] = ''
|
||||
self.fields[k].widget.is_required = False
|
||||
self.fields[k].required = False
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import EmailValidator
|
||||
from django.db.models.functions import Upper
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes.forms import SafeModelChoiceField
|
||||
|
||||
from pretix.base.email import get_available_placeholders
|
||||
@@ -115,7 +115,6 @@ class VoucherForm(I18nModelForm):
|
||||
'event': instance.event.slug,
|
||||
'organizer': instance.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'Date')
|
||||
}
|
||||
)
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
@@ -208,12 +207,15 @@ class VoucherForm(I18nModelForm):
|
||||
if self.instance and self.instance.pk:
|
||||
cnt -= self.instance.redeemed # these do not need quota any more
|
||||
|
||||
Voucher.clean_item_properties(
|
||||
data, self.instance.event,
|
||||
self.instance.quota, self.instance.item, self.instance.variation,
|
||||
seats_given=data.get('seat') or data.get('seats'),
|
||||
block_quota=data.get('block_quota')
|
||||
)
|
||||
try:
|
||||
Voucher.clean_item_properties(
|
||||
data, self.instance.event,
|
||||
self.instance.quota, self.instance.item, self.instance.variation,
|
||||
seats_given=data.get('seat') or data.get('seats'),
|
||||
block_quota=data.get('block_quota')
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise ValidationError({"itemvar": e.message})
|
||||
if not data.get('show_hidden_items') and (
|
||||
(self.instance.quota and all(i.hide_without_voucher for i in self.instance.quota.items.all()))
|
||||
or (self.instance.item and self.instance.item.hide_without_voucher)
|
||||
@@ -224,10 +226,17 @@ class VoucherForm(I18nModelForm):
|
||||
'them.')
|
||||
]
|
||||
})
|
||||
Voucher.clean_subevent(
|
||||
data, self.instance.event
|
||||
)
|
||||
Voucher.clean_max_usages(data, self.instance.redeemed)
|
||||
|
||||
try:
|
||||
Voucher.clean_subevent(
|
||||
data, self.instance.event
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise ValidationError({"subevent": e.message})
|
||||
try:
|
||||
Voucher.clean_max_usages(data, self.instance.redeemed)
|
||||
except ValidationError as e:
|
||||
raise ValidationError({"max_usages": e.message})
|
||||
check_quota = Voucher.clean_quota_needs_checking(
|
||||
data, self.initial_instance_data,
|
||||
item_changed=data.get('itemvar') != self.initial.get('itemvar'),
|
||||
@@ -294,7 +303,7 @@ class VoucherBulkForm(VoucherForm):
|
||||
}),
|
||||
required=False,
|
||||
help_text=_('You can either supply a list of email addresses with one email address per line, or the contents '
|
||||
'of a CSV file with a title column and one or more of the columns "email", "number", "name", '
|
||||
'of a CSV file with a title row and one or more of the columns "email", "number", "name", '
|
||||
'or "tag".')
|
||||
)
|
||||
Recipient = namedtuple('Recipient', 'email number name tag')
|
||||
|
||||
@@ -43,9 +43,11 @@ from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape, format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.datasync.datasync import datasync_providers
|
||||
from pretix.base.logentrytypes import (
|
||||
DiscountLogEntryType, EventLogEntryType, ItemCategoryLogEntryType,
|
||||
ItemLogEntryType, LogEntryType, OrderLogEntryType, QuestionLogEntryType,
|
||||
@@ -421,6 +423,51 @@ class OrderPrintLogEntryType(OrderLogEntryType):
|
||||
)
|
||||
|
||||
|
||||
class OrderDataSyncLogEntryType(OrderLogEntryType):
|
||||
def display(self, logentry, data):
|
||||
try:
|
||||
from pretix.base.datasync.datasync import datasync_providers
|
||||
provider_class, meta = datasync_providers.get(identifier=data['provider'])
|
||||
data['provider_display_name'] = provider_class.display_name
|
||||
except (KeyError, AttributeError):
|
||||
data['provider_display_name'] = data.get('provider')
|
||||
return super().display(logentry, data)
|
||||
|
||||
|
||||
@log_entry_types.new_from_dict({
|
||||
"pretix.event.order.data_sync.success": _("Data successfully transferred to {provider_display_name}."),
|
||||
})
|
||||
class OrderDataSyncSuccessLogEntryType(OrderDataSyncLogEntryType):
|
||||
def display(self, logentry, data):
|
||||
links = []
|
||||
if data.get('provider') and data.get('objects'):
|
||||
prov, meta = datasync_providers.get(identifier=data['provider'])
|
||||
if prov:
|
||||
for objs in data['objects'].values():
|
||||
links.append(", ".join(
|
||||
prov.get_external_link_html(logentry.event, obj['external_link_href'], obj['external_link_display_name'])
|
||||
for obj in objs
|
||||
if obj and 'external_link_href' in obj and 'external_link_display_name' in obj
|
||||
))
|
||||
|
||||
return mark_safe(escape(super().display(logentry, data)) + "".join("<p>" + link + "</p>" for link in links))
|
||||
|
||||
|
||||
@log_entry_types.new_from_dict({
|
||||
"pretix.event.order.data_sync.failed.config": _("Transferring data to {provider_display_name} failed due to invalid configuration:"),
|
||||
"pretix.event.order.data_sync.failed.exceeded": _("Maximum number of retries exceeded while transferring data to {provider_display_name}:"),
|
||||
"pretix.event.order.data_sync.failed.permanent": _("Error while transferring data to {provider_display_name}:"),
|
||||
"pretix.event.order.data_sync.failed.internal": _("Internal error while transferring data to {provider_display_name}."),
|
||||
"pretix.event.order.data_sync.failed.timeout": _("Internal error while transferring data to {provider_display_name}."),
|
||||
})
|
||||
class OrderDataSyncErrorLogEntryType(OrderDataSyncLogEntryType):
|
||||
def display(self, logentry, data):
|
||||
errmes = data["error"]
|
||||
if not isinstance(errmes, list):
|
||||
errmes = [errmes]
|
||||
return mark_safe(escape(super().display(logentry, data)) + "".join("<p>" + escape(msg) + "</p>" for msg in errmes))
|
||||
|
||||
|
||||
@receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display")
|
||||
def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
|
||||
@@ -722,6 +769,7 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
|
||||
'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'),
|
||||
'pretix.team.token.created': _('The token "{name}" has been created.'),
|
||||
'pretix.team.token.deleted': _('The token "{name}" has been revoked.'),
|
||||
'pretix.event.checkin.reset': _('The check-in and print log state has been reset.')
|
||||
})
|
||||
class CoreLogEntryType(LogEntryType):
|
||||
pass
|
||||
|
||||
@@ -451,6 +451,11 @@ def get_global_navigation(request):
|
||||
'url': reverse('control:global.sysreport'),
|
||||
'active': (url.url_name == 'global.sysreport'),
|
||||
},
|
||||
{
|
||||
'label': _('Data sync problems'),
|
||||
'url': reverse('control:global.datasync.failedjobs'),
|
||||
'active': (url.url_name == 'global.datasync.failedjobs'),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -655,6 +660,18 @@ def get_organizer_navigation(request):
|
||||
'icon': 'download',
|
||||
})
|
||||
|
||||
if 'can_change_organizer_settings' in request.orgapermset:
|
||||
merge_in(nav, [{
|
||||
'parent': reverse('control:organizer.export', kwargs={
|
||||
'organizer': request.organizer.slug,
|
||||
}),
|
||||
'label': _('Data sync problems'),
|
||||
'url': reverse('control:organizer.datasync.failedjobs', kwargs={
|
||||
'organizer': request.organizer.slug,
|
||||
}),
|
||||
'active': (url.url_name == 'organizer.datasync.failedjobs'),
|
||||
}])
|
||||
|
||||
merge_in(nav, sorted(
|
||||
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
|
||||
[]),
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
</li>
|
||||
{% elif request.user.is_staff and staff_session %}
|
||||
<li>
|
||||
<a href="{% url 'control:user.sudo.stop' %}" class="danger">
|
||||
<a href="{% url 'control:user.sudo.stop' %}" class="danger admin-only">
|
||||
<i class="fa fa-id-card"></i> {% trans "End admin session" %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -83,6 +83,13 @@
|
||||
<a href="{% url "control:organizer.devices" organizer=request.organizer.slug %}"
|
||||
class="btn btn-default"><i class="fa fa-tablet"></i> {% trans "Connected devices" %}</a>
|
||||
{% endif %}
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<a href="{% url "control:event.orders.checkinlists.reset" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-default">
|
||||
<span class="fa fa-repeat"></span>
|
||||
{% trans "Reset check-in" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "pretixcontrol/items/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Reset check-in" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Reset check-in" %}</h1>
|
||||
<form action="" method="post" class="" data-asynctask>
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
With this feature, you can reset the entire check-in state of the event.
|
||||
This will delete all check-in records as well as all records of printed tickets or badges.
|
||||
We recommend to use this feature after testing your hardware setup but only before your
|
||||
event started, and you admitted any real attendees or printed any real badges or tickets.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p class="alert alert-danger">
|
||||
{% blocktrans trimmed count count=checkins %}
|
||||
This will permanently delete <strong>1 check-in</strong>.
|
||||
{% plural %}
|
||||
This will permanently delete <strong>{{ count }} check-ins</strong>.
|
||||
{% endblocktrans %}
|
||||
{% blocktrans trimmed count count=printlogs %}
|
||||
Additionally, <strong>1 print log</strong> will be deleted.
|
||||
{% plural %}
|
||||
Additionally, <strong>{{ count }} print logs</strong> will be deleted.
|
||||
{% endblocktrans %}
|
||||
<br>
|
||||
<strong>
|
||||
{% trans "This cannot be reverted!" %}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
The deleted entries will still show up in the "Order history" section, but for all other
|
||||
purposes the system will behave as if they never existed.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_form form layout="inline" %}
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:event.orders.checkinlists" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Proceed with reset" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load bootstrap3 %}
|
||||
{% load escapejson %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Data transfer to external systems" %}
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
{% for identifier, display_name, pending, objects in providers %}
|
||||
<li class="list-group-item">
|
||||
<form action="{% url "control:event.order.sync_job" organizer=event.organizer.slug event=event.slug code=order.code provider=identifier %}" method="post" class="form-inline pull-right">
|
||||
{% csrf_token %}
|
||||
{% if pending %}
|
||||
{% if pending.not_before > now or pending.need_manual_retry %}
|
||||
<button type="submit" name="run_job_now" value="{{ pending.pk }}" class="btn btn-default"><i class="fa fa-refresh"></i> {% trans "Retry now" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" name="cancel_job" value="{{ pending.pk }}" class="btn btn-danger"><i class="fa fa-times"></i> {% trans "Cancel" %}</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-default"><i class="fa fa-refresh"></i> {% trans "Sync now" %}</button>
|
||||
<input type="hidden" name="queue_sync" value="true">
|
||||
{% endif %}
|
||||
</form>
|
||||
<p><b>{{ display_name }}</b></p>
|
||||
{% if pending %}
|
||||
<p>
|
||||
{% if pending.need_manual_retry %}
|
||||
<i class="fa fa-warning"></i>
|
||||
{% trans "Error" %}: {{ pending.get_need_manual_retry_display }}
|
||||
{% elif pending.failed_attempts %}
|
||||
<i class="fa fa-warning"></i>
|
||||
{% blocktrans trimmed with num=pending.failed_attempts max=pending.max_retry_attempts %}
|
||||
Error. Retry {{ num }} of {{ max }}.
|
||||
{% endblocktrans %}
|
||||
{% if pending.not_before %}
|
||||
{% blocktrans trimmed with datetime=pending.not_before|date:"SHORT_DATETIME_FORMAT" %}
|
||||
Waiting until {{ datetime }}
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% elif pending.not_before > now %}
|
||||
{% blocktrans trimmed with datetime=pending.not_before|date:"SHORT_DATETIME_FORMAT" %}
|
||||
Waiting until {{ datetime }}
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
<i class="fa fa-hourglass"></i> {% trans "Pending" %}
|
||||
{% endif %}
|
||||
<span class="text-muted">({% blocktrans trimmed with datetime=pending.triggered|date:"SHORT_DATETIME_FORMAT" %}triggered at {{ datetime }}
|
||||
{% endblocktrans %})</span>
|
||||
<!-- {{ pending.triggered_by }} / {{ pending.triggered }} -->
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<ul>
|
||||
{% for obj in objects %}
|
||||
<li>
|
||||
{% if obj.external_link_html %}
|
||||
{{ obj.external_link_html }}
|
||||
{% else %}
|
||||
{{ obj.external_object_type }}
|
||||
{% trans "identified by" %} {{ obj.external_id_field }}
|
||||
<em>{{ obj.id_value }}</em>
|
||||
{% endif %}
|
||||
<time class="text-muted" datetime="{{ obj.transmitted.isoformat }}">{{ obj.transmitted|date:"SHORT_DATETIME_FORMAT" }}</time>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>{% trans "No data transmitted." %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,86 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{% trans "Sync problems" %}</h2>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
On this page, we provide a list of orders where data synchronisation to an external system has failed.
|
||||
You can start another attempt to sync them manually.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{% if queue_items %}
|
||||
<label aria-label="{% trans "select all rows for batch-operation" %}"
|
||||
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
|
||||
{% endif %}
|
||||
</th>
|
||||
<th>{% trans "Order" %}</th>
|
||||
<th>{% trans "Sync provider" %}</th>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "Failure mode" %}</th>
|
||||
{% if staff_session %}
|
||||
<th>in_flight</th><th>retry</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in queue_items %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="idlist" value="{{ item.pk }}"></td>
|
||||
<td>
|
||||
{% if staff_session %}{{ item.order.event.organizer.slug }} -{% endif %}
|
||||
<a href="{% url "control:event.order" event=item.order.event.slug organizer=item.order.event.organizer.slug code=item.order.code %}">
|
||||
{{ item.order.full_code }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ item.provider_display_name }}</td>
|
||||
<td>
|
||||
{{ item.triggered }}
|
||||
{% if staff_session %}({{ item.triggered_by }}){% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.need_manual_retry %}
|
||||
{{ item.get_need_manual_retry_display }}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with datetime=item.not_before|date:"SHORT_DATETIME_FORMAT" %}
|
||||
Temporary error, will retry after {{ datetime }}
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% if staff_session %}({{ item.need_manual_retry }}){% endif %}
|
||||
</td>
|
||||
{% if staff_session %}
|
||||
<td>{{ item.in_flight }} ({{ item.in_flight_since }})</td><td>{{ item.failed_attempts }} / {{ item.max_retry_attempts }} ({{ item.not_before }})</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">{% trans "No problems." %}</td>
|
||||
{% if staff_session %}
|
||||
<td></td><td></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% if queue_items %}
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<button type="submit" name="action" value="retry" class="btn btn-primary"><i class="fa fa-refresh"></i> {% trans "Retry selected" %}</button>
|
||||
<button type="submit" name="action" value="cancel" class="btn btn-danger"><i class="fa fa-times"></i> {% trans "Cancel selected" %}</button>
|
||||
</td>
|
||||
{% if staff_session %}
|
||||
<td></td><td></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
</table>
|
||||
</form>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load escapejson %}
|
||||
{% load formset_tags %}
|
||||
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
|
||||
{{ formset.management_form }}
|
||||
{% bootstrap_formset_errors formset %}
|
||||
<div data-formset-body>
|
||||
{% for f in formset %}
|
||||
{% bootstrap_form_errors f %}
|
||||
<div class="row formset-row" data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ f.id }}
|
||||
{% bootstrap_field f.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field f.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field f.pretix_field layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field f.external_field layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
{% bootstrap_field f.overwrite layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
{{ f.value_map.as_hidden }}
|
||||
<div class="col-md-2 text-right flip">
|
||||
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
|
||||
|
||||
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
|
||||
data-target="#editValueMapModal" title="{% trans "Edit value mapping" %}">
|
||||
<i class="fa fa-edit"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script type="form-template" data-formset-empty-form>
|
||||
{% escapescript %}
|
||||
<div class="row formset-row" data-formset-form>
|
||||
<div class="sr-only">
|
||||
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field formset.empty_form.pretix_field layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field formset.empty_form.external_field layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
{% bootstrap_field formset.empty_form.overwrite layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
{{ f.value_map.as_hidden }}
|
||||
<div class="col-md-2 text-right flip">
|
||||
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
|
||||
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
|
||||
data-target="#editValueMapModal" title="{% trans "Edit value mapping" %}">
|
||||
<i class="fa fa-edit"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
</script>
|
||||
<p>
|
||||
<button type="button" class="btn btn-default" data-formset-add>
|
||||
<i class="fa fa-plus"></i> {% trans "Add property" %}</button>
|
||||
</p>
|
||||
</div>
|
||||
{% if external_fields %}
|
||||
{{ external_fields|json_script:external_fields_id }}
|
||||
{% endif %}
|
||||
@@ -15,7 +15,7 @@
|
||||
<span class="optional">{% trans "Optional" %}</span>
|
||||
</label>
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field form.geo_lat layout="inline" %}
|
||||
{% bootstrap_field form.geo_lat layout="inline" placeholder=_("Latitude") %}
|
||||
{% if global_settings.opencagedata_apikey %}
|
||||
<p class="attrib">
|
||||
<a href="https://openstreetmap.org/" target="_blank">
|
||||
@@ -25,7 +25,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field form.geo_lon layout="inline" %}
|
||||
{% bootstrap_field form.geo_lon layout="inline" placeholder=_("Longitude") %}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,15 @@
|
||||
class="btn btn-primary">{% trans "Show affected orders" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_sync_problems %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
Orders in this event could not be <strong>synced to an external system</strong> as configured.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.datasync.failedjobs" event=request.event.slug organizer=request.event.organizer.slug %}"
|
||||
class="btn btn-primary">{% trans "Show sync problems" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %}
|
||||
|
||||
{% if request.event.has_subevents %}
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<div class="col-lg-6 col-sm-12 col-xs-12">
|
||||
{{ log.display }}
|
||||
{% if staff_session %}
|
||||
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
|
||||
<a href="" class="btn btn-default btn-xs admin-only" data-expandlogs data-id="{{ log.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<div class="col-lg-6 col-sm-12 col-xs-12">
|
||||
{{ log.display }}
|
||||
{% if staff_session %}
|
||||
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
|
||||
<a href="" class="btn btn-default btn-xs admin-only" data-expandlogs data-id="{{ log.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
{% trans "Every event needs to be created as part of an organizer account. Currently, you do not have access to any organizer accounts." %}
|
||||
</div>
|
||||
{% if staff_session %}
|
||||
<a href="{% url "control:organizers.add" %}" class="btn btn-default">
|
||||
<a href="{% url "control:organizers.add" %}" class="btn btn-default admin-only">
|
||||
{% trans "Create a new organizer" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
{% bootstrap_field form.name layout="control" %}
|
||||
<div class="form-group">
|
||||
<div class="form-group{% if form.slug.errors %} has-error{% endif %}">
|
||||
<label class="col-md-3 control-label" for="{{ form.slug.id_for_label }}">{{ form.slug.label }}</label>
|
||||
<div class="col-md-9 form-inline">
|
||||
<button class="btn btn-default pull-right flip" type="button" id="event-slug-random-generate"
|
||||
@@ -14,6 +14,9 @@
|
||||
{% trans "Set to random" %}
|
||||
</button>
|
||||
{% bootstrap_field form.slug form_group_class="helper-display-inline" show_label=False layout="inline" %}
|
||||
{% for error in form.slug.errors %}
|
||||
<div class="help-block">{{ error }}</div>
|
||||
{% endfor %}
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
This is the address users can buy your tickets at. Should be short, only contain lowercase
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<p>
|
||||
{{ log.display }}
|
||||
{% if staff_session %}
|
||||
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
|
||||
<a href="" class="btn btn-default btn-xs admin-only" data-expandlogs data-id="{{ log.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
|
||||
@@ -500,6 +500,18 @@
|
||||
{% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if staff_session %}
|
||||
<div class="admin-only print-logs">
|
||||
{% for pl in line.print_logs.all %}
|
||||
<span class="fa fa-print"></span>
|
||||
{{ pl.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{{ pl.get_type_display }}
|
||||
({{ pl.source }}{% if pl.device %}, #{{ pl.device.device_id }}{% endif %})
|
||||
{% if not pl.successful %}<span class="fa fa-warning fa-fw"></span>{% endif %}
|
||||
<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if line.issued_gift_cards %}
|
||||
<dl>
|
||||
{% for gc in line.issued_gift_cards.all %}
|
||||
@@ -788,7 +800,7 @@
|
||||
{{ p.html_info|safe }}
|
||||
{% if staff_session %}
|
||||
<p>
|
||||
<a href="" class="btn btn-default btn-xs" data-expandpayment data-id="{{ p.pk }}">
|
||||
<a href="" class="btn btn-default btn-xs admin-only" data-expandpayment data-id="{{ p.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
@@ -800,7 +812,7 @@
|
||||
<tr>
|
||||
<td colspan="1"></td>
|
||||
<td colspan="5">
|
||||
<a href="" class="btn btn-default btn-xs" data-expandpayment data-id="{{ p.pk }}">
|
||||
<a href="" class="btn btn-default btn-xs admin-only" data-expandpayment data-id="{{ p.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
@@ -904,7 +916,7 @@
|
||||
{% endif %}
|
||||
{% if staff_session %}
|
||||
<p>
|
||||
<a href="" class="btn btn-default btn-xs" data-expandrefund
|
||||
<a href="" class="btn btn-default btn-xs admin-only" data-expandrefund
|
||||
data-id="{{ r.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
|
||||
@@ -21,8 +21,17 @@
|
||||
<script type="text/json" data-replace-with-qr>{{ qrdata|safe }}</script><br>
|
||||
{% trans "If your app/device does not support scanning a QR code, you can also enter the following information:" %}
|
||||
<br>
|
||||
<strong>{% trans "System URL:" %}</strong> <code>{{ settings.SITE_URL }}</code><br>
|
||||
<strong>{% trans "Token:" %}</strong> <code>{{ device.initialization_token }}</code>
|
||||
<strong>{% trans "System URL:" %}</strong> <code id="system_url">{{ settings.SITE_URL }}</code>
|
||||
<button type="button" class="btn btn-default btn-xs btn-clipboard js-only" data-clipboard-target="#system_url">
|
||||
<i class="fa fa-clipboard" aria-hidden="true"></i>
|
||||
<span class="sr-only">{% trans "Copy to clipboard" %}</span>
|
||||
</button>
|
||||
<br>
|
||||
<strong>{% trans "Token:" %}</strong> <code id="init_token">{{ device.initialization_token }}</code>
|
||||
<button type="button" class="btn btn-default btn-xs btn-clipboard js-only" data-clipboard-target="#init_token">
|
||||
<i class="fa fa-clipboard" aria-hidden="true"></i>
|
||||
<span class="sr-only">{% trans "Copy to clipboard" %}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<div class="col-lg-6 col-sm-12 col-xs-12">
|
||||
{{ log.display }}
|
||||
{% if staff_session %}
|
||||
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
|
||||
<a href="" class="btn btn-default btn-xs admin-only" data-expandlogs data-id="{{ log.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{% blocktrans with name=organizer.name %}Organizer settings{% endblocktrans %}
|
||||
{% if request.user.is_staff and staff_session %}
|
||||
<a href="{% url "control:organizer.delete" organizer=organizer.slug %}"
|
||||
class="btn btn-danger hidden-print">
|
||||
class="btn btn-danger hidden-print admin-only">
|
||||
<span class="fa fa-trash"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if staff_session and t.info %}
|
||||
<pre><code>{{ t.info|pprint }}</code></pre>
|
||||
<pre class="admin-only"><code>{{ t.info|pprint }}</code></pre>
|
||||
{% endif %}
|
||||
{% if t.acceptor and t.acceptor != request.organizer %}
|
||||
<span class="text-muted">
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
{% if staff_session %}
|
||||
<p>
|
||||
<a href="{% url "control:organizers.add" %}" class="btn btn-default">
|
||||
<a href="{% url "control:organizers.add" %}" class="btn btn-default admin-only">
|
||||
<span class="fa fa-plus"></span>
|
||||
{% trans "Create a new organizer" %}
|
||||
</a>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="col-lg-6 col-sm-12 col-xs-12">
|
||||
{{ log.display }}
|
||||
{% if staff_session %}
|
||||
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
|
||||
<a href="" class="btn btn-default btn-xs admin-only" data-expandlogs data-id="{{ log.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
<tr>
|
||||
<td colspan="1"></td>
|
||||
<td colspan="6">
|
||||
<a href="" class="btn btn-default btn-xs" data-expandpayment data-id="{{ p.pk }}">
|
||||
<a href="" class="btn btn-default btn-xs admin-only" data-expandpayment data-id="{{ p.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
|
||||
@@ -289,13 +289,13 @@
|
||||
{% bootstrap_field f.DELETE form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
{% bootstrap_field f.time_from layout="inline" %}
|
||||
{% bootstrap_field f.time_from layout="inline" placeholder=time_begin_sample %}
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
{% bootstrap_field f.time_to layout="inline" %}
|
||||
{% bootstrap_field f.time_to layout="inline" placeholder=time_end_sample %}
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
{% bootstrap_field f.time_admission layout="inline" %}
|
||||
{% bootstrap_field f.time_admission layout="inline" placeholder=time_admission_sample %}
|
||||
</div>
|
||||
<div class="col-sm-1 text-right flip">
|
||||
<button type="button" class="btn btn-danger btn-block"
|
||||
@@ -315,13 +315,13 @@
|
||||
{% bootstrap_field time_formset.empty_form.DELETE form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
{% bootstrap_field time_formset.empty_form.time_from layout="inline" %}
|
||||
{% bootstrap_field time_formset.empty_form.time_from layout="inline" placeholder=time_begin_sample %}
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
{% bootstrap_field time_formset.empty_form.time_to layout="inline" %}
|
||||
{% bootstrap_field time_formset.empty_form.time_to layout="inline" placeholder=time_end_sample %}
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
{% bootstrap_field time_formset.empty_form.time_admission layout="inline" %}
|
||||
{% bootstrap_field time_formset.empty_form.time_admission layout="inline" placeholder=time_admission_sample %}
|
||||
</div>
|
||||
<div class="col-sm-1 text-right flip">
|
||||
<button type="button" class="btn btn-danger btn-block"
|
||||
@@ -338,13 +338,13 @@
|
||||
<label for="subevent_add_many_slots_first">
|
||||
<strong>{% trans "Start of first slot" %}</strong>
|
||||
</label>
|
||||
<input class="form-control timepickerfield" id="subevent_add_many_slots_first" value="{{ time_begin_sample }}">
|
||||
<input class="form-control timepickerfield" id="subevent_add_many_slots_first" value="{{ time_begin_sample }}" placeholder="{{ time_begin_sample }}">
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-12">
|
||||
<label for="subevent_add_many_slots_end">
|
||||
<strong>{% trans "End of time slots" %}</strong>
|
||||
</label>
|
||||
<input class="form-control timepickerfield" id="subevent_add_many_slots_end" value="{{ time_end_sample }}">
|
||||
<input class="form-control timepickerfield" id="subevent_add_many_slots_end" value="{{ time_end_sample }}" placeholder="{{ time_end_sample }}">
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12">
|
||||
<label for="subevent_add_many_slots_length">
|
||||
@@ -409,8 +409,8 @@
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Timeline" %}</legend>
|
||||
{% bootstrap_field form.rel_presale_start layout="control" %}
|
||||
{% bootstrap_field form.rel_presale_end layout="control" %}
|
||||
{% bootstrap_field form.rel_presale_start layout="control" placeholder=datetime_sample %}
|
||||
{% bootstrap_field form.rel_presale_end layout="control" placeholder=datetime_sample %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Quotas" %}</legend>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<div class="field-content">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{% bootstrap_field form.geo_lat layout="inline" %}
|
||||
{% bootstrap_field form.geo_lat layout="inline" placeholder=_("Latitude") %}
|
||||
{% if global_settings.opencagedata_apikey %}
|
||||
<p class="attrib">
|
||||
<a href="https://openstreetmap.org/" target="_blank" tabindex="-1">
|
||||
@@ -61,7 +61,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% bootstrap_field form.geo_lon layout="inline" %}
|
||||
{% bootstrap_field form.geo_lon layout="inline" placeholder=_("Longitude") %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,9 +37,9 @@ from django.urls import include, re_path
|
||||
from django.views.generic.base import RedirectView
|
||||
|
||||
from pretix.control.views import (
|
||||
auth, checkin, dashboards, discounts, event, geo, global_settings, item,
|
||||
main, modelimport, oauth, orders, organizer, pdf, search, shredder,
|
||||
subevents, typeahead, user, users, vouchers, waitinglist,
|
||||
auth, checkin, dashboards, datasync, discounts, event, geo,
|
||||
global_settings, item, main, modelimport, oauth, orders, organizer, pdf,
|
||||
search, shredder, subevents, typeahead, user, users, vouchers, waitinglist,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -58,6 +58,7 @@ urlpatterns = [
|
||||
re_path(r'^global/license/$', global_settings.LicenseCheckView.as_view(), name='global.license'),
|
||||
re_path(r'^global/sysreport/$', global_settings.SysReportView.as_view(), name='global.sysreport'),
|
||||
re_path(r'^global/message/$', global_settings.MessageView.as_view(), name='global.message'),
|
||||
re_path(r'^global/datasync/failedjobs/$', datasync.GlobalFailedSyncJobsView.as_view(), name='global.datasync.failedjobs'),
|
||||
re_path(r'^logdetail/$', global_settings.LogDetailView.as_view(), name='global.logdetail'),
|
||||
re_path(r'^logdetail/payment/$', global_settings.PaymentDetailView.as_view(), name='global.paymentdetail'),
|
||||
re_path(r'^logdetail/refund/$', global_settings.RefundDetailView.as_view(), name='global.refunddetail'),
|
||||
@@ -248,6 +249,7 @@ urlpatterns = [
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/delete$', organizer.DeleteScheduledExportView.as_view(),
|
||||
name='organizer.export.scheduled.delete'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/ticket_select2$', typeahead.ticket_select2, name='organizer.ticket_select2'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/datasync/failedjobs/$', datasync.OrganizerFailedSyncJobsView.as_view(), name='organizer.datasync.failedjobs'),
|
||||
re_path(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'),
|
||||
re_path(r'^events/$', main.EventList.as_view(), name='events'),
|
||||
re_path(r'^events/add$', main.EventWizard.as_view(), name='events.add'),
|
||||
@@ -428,6 +430,8 @@ urlpatterns = [
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/cancellationrequests/(?P<req>\d+)/delete$',
|
||||
orders.OrderCancellationRequestDelete.as_view(),
|
||||
name='event.order.cancellationrequests.delete'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/sync_job/(?P<provider>[^/]+)/$', datasync.ControlSyncJob.as_view(),
|
||||
name='event.order.sync_job'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transactions/$', orders.OrderTransactions.as_view(), name='event.order.transactions'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/$', orders.OrderDetail.as_view(), name='event.order'),
|
||||
re_path(r'^invoice/(?P<invoice>[^/]+)$', orders.InvoiceDownload.as_view(),
|
||||
@@ -464,6 +468,7 @@ urlpatterns = [
|
||||
re_path(r'^checkins/$', checkin.CheckinListView.as_view(), name='event.orders.checkins'),
|
||||
re_path(r'^checkinlists/$', checkin.CheckinListList.as_view(), name='event.orders.checkinlists'),
|
||||
re_path(r'^checkinlists/add$', checkin.CheckinListCreate.as_view(), name='event.orders.checkinlists.add'),
|
||||
re_path(r'^checkinlists/reset$', checkin.CheckInResetView.as_view(), name='event.orders.checkinlists.reset'),
|
||||
re_path(r'^checkinlists/select2$', typeahead.checkinlist_select2, name='event.orders.checkinlists.select2'),
|
||||
re_path(r'^checkinlists/(?P<list>\d+)/$', checkin.CheckInListShow.as_view(), name='event.orders.checkinlists.show'),
|
||||
re_path(r'^checkinlists/(?P<list>\d+)/simulator$', checkin.CheckInListSimulator.as_view(), name='event.orders.checkinlists.simulator'),
|
||||
@@ -473,6 +478,7 @@ urlpatterns = [
|
||||
name='event.orders.checkinlists.edit'),
|
||||
re_path(r'^checkinlists/(?P<list>\d+)/delete$', checkin.CheckinListDelete.as_view(),
|
||||
name='event.orders.checkinlists.delete'),
|
||||
re_path(r'^datasync/failedjobs/$', datasync.EventFailedSyncJobsView.as_view(), name='event.datasync.failedjobs'),
|
||||
])),
|
||||
re_path(r'^event/(?P<organizer>[^/]+)/$', RedirectView.as_view(pattern_name='control:organizer'), name='event.organizerredirect'),
|
||||
]
|
||||
|
||||
@@ -50,15 +50,16 @@ from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.api.views.checkin import _redeem_process
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import Checkin, Order, OrderPosition
|
||||
from pretix.base.models import Checkin, LogEntry, Order, OrderPosition
|
||||
from pretix.base.models.checkin import CheckinList
|
||||
from pretix.base.models.orders import PrintLog
|
||||
from pretix.base.services.checkin import (
|
||||
LazyRuleVars, _logic_annotate_for_graphic_explain,
|
||||
)
|
||||
from pretix.base.signals import checkin_created
|
||||
from pretix.base.views.tasks import AsyncPostView
|
||||
from pretix.base.views.tasks import AsyncFormView, AsyncPostView
|
||||
from pretix.control.forms.checkin import (
|
||||
CheckinListForm, CheckinListSimulatorForm,
|
||||
CheckinListForm, CheckinListSimulatorForm, CheckinResetForm,
|
||||
)
|
||||
from pretix.control.forms.filter import (
|
||||
CheckinFilterForm, CheckinListAttendeeFilterForm, CheckinListFilterForm,
|
||||
@@ -570,3 +571,55 @@ class CheckInListSimulator(EventPermissionRequiredMixin, FormView):
|
||||
for q in self.result["questions"]:
|
||||
q["question"] = LazyI18nString(q["question"])
|
||||
return self.get(self.request, self.args, self.kwargs)
|
||||
|
||||
|
||||
class CheckInResetView(CheckInListQueryMixin, EventPermissionRequiredMixin, AsyncFormView):
|
||||
form_class = CheckinResetForm
|
||||
permission = "can_change_orders"
|
||||
template_name = "pretixcontrol/checkin/reset.html"
|
||||
|
||||
def get_error_url(self, *args):
|
||||
return reverse(
|
||||
"control:event.orders.checkinlists",
|
||||
kwargs={
|
||||
"event": self.request.event.slug,
|
||||
"organizer": self.request.organizer.slug,
|
||||
},
|
||||
)
|
||||
|
||||
def get_success_url(self, *args):
|
||||
return reverse(
|
||||
"control:event.orders.checkinlists",
|
||||
kwargs={
|
||||
"event": self.request.event.slug,
|
||||
"organizer": self.request.organizer.slug,
|
||||
},
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['checkins'] = Checkin.all.filter(list__event=self.request.event).count()
|
||||
ctx['printlogs'] = PrintLog.objects.filter(position__order__event=self.request.event).count()
|
||||
return ctx
|
||||
|
||||
def async_form_valid(self, task, form):
|
||||
with transaction.atomic():
|
||||
qs = Checkin.all.filter(list__event=self.request.event).select_related("position", "position__order")
|
||||
logentries = []
|
||||
for ci in qs:
|
||||
if ci.position:
|
||||
logentries.append(ci.position.order.log_action('pretix.event.checkin.reverted', data={
|
||||
'position': ci.position.id,
|
||||
'positionid': ci.position.positionid,
|
||||
'list': ci.list_id,
|
||||
'web': True
|
||||
}, user=self.request.user, save=False))
|
||||
|
||||
Order.objects.filter(pk__in=qs.values_list("position__order_id", flat=True)).update(last_modified=now())
|
||||
qs.delete()
|
||||
LogEntry.objects.bulk_create(logentries)
|
||||
|
||||
pl = PrintLog.objects.filter(position__order__event=self.request.event)
|
||||
pl.delete()
|
||||
self.request.event.log_action('pretix.event.checkin.reset', user=self.request.user)
|
||||
self.request.event.cache.clear()
|
||||
|
||||
@@ -383,6 +383,10 @@ def event_index(request, organizer, event):
|
||||
ctx['has_cancellation_requests'] = can_view_orders and CancellationRequest.objects.filter(
|
||||
order__event=request.event
|
||||
).exists()
|
||||
ctx['has_sync_problems'] = can_change_event_settings and request.event.queued_sync_jobs.filter(
|
||||
Q(need_manual_retry__isnull=False)
|
||||
| Q(failed_attempts__gt=0)
|
||||
).exists()
|
||||
|
||||
ctx['timeline'] = [
|
||||
{
|
||||
|
||||
150
src/pretix/control/views/datasync.py
Normal file
150
src/pretix/control/views/datasync.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from itertools import groupby
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpResponseNotAllowed
|
||||
from django.shortcuts import redirect
|
||||
from django.template.loader import get_template
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import ListView
|
||||
|
||||
from pretix.base.datasync.datasync import datasync_providers
|
||||
from pretix.base.models import Event, Order
|
||||
from pretix.base.models.datasync import OrderSyncQueue
|
||||
from pretix.control.permissions import (
|
||||
AdministratorPermissionRequiredMixin, EventPermissionRequiredMixin,
|
||||
OrganizerPermissionRequiredMixin,
|
||||
)
|
||||
from pretix.control.signals import order_info
|
||||
from pretix.control.views.orders import OrderView
|
||||
|
||||
|
||||
@receiver(order_info, dispatch_uid="datasync_control_order_info")
|
||||
def on_control_order_info(sender: Event, request, order: Order, **kwargs):
|
||||
providers = [provider for provider, meta in datasync_providers.filter(active_in=sender)]
|
||||
if not providers:
|
||||
return ""
|
||||
|
||||
queued = {p.sync_provider: p for p in order.queued_sync_jobs.all()}
|
||||
objects = {
|
||||
provider: list(objects)
|
||||
for (provider, objects)
|
||||
in groupby(order.sync_results.order_by('sync_provider').all(), key=lambda o: o.sync_provider)
|
||||
}
|
||||
providers = [(provider.identifier, provider.display_name, queued.get(provider.identifier), objects.get(provider.identifier)) for provider in providers]
|
||||
|
||||
template = get_template("pretixcontrol/datasync/control_order_info.html")
|
||||
ctx = {
|
||||
"order": order,
|
||||
"request": request,
|
||||
"event": sender,
|
||||
"providers": providers,
|
||||
"now": now(),
|
||||
}
|
||||
return template.render(ctx, request=request)
|
||||
|
||||
|
||||
class ControlSyncJob(OrderView):
|
||||
permission = 'can_change_orders'
|
||||
|
||||
def post(self, request, provider, *args, **kwargs):
|
||||
prov, meta = datasync_providers.get(active_in=self.request.event, identifier=provider)
|
||||
|
||||
if self.request.POST.get("queue_sync") == "true":
|
||||
prov.enqueue_order(self.order, 'user')
|
||||
messages.success(self.request, _('The sync job has been enqueued and will run in the next minutes.'))
|
||||
elif self.request.POST.get("cancel_job"):
|
||||
job = self.order.queued_sync_jobs.get(pk=self.request.POST.get("cancel_job"))
|
||||
if job.in_flight:
|
||||
messages.warning(self.request, _('The sync job is already in progress.'))
|
||||
else:
|
||||
job.delete()
|
||||
messages.success(self.request, _('The sync job has been canceled.'))
|
||||
elif self.request.POST.get("run_job_now"):
|
||||
job = self.order.queued_sync_jobs.get(pk=self.request.POST.get("run_job_now"))
|
||||
job.not_before = now()
|
||||
job.need_manual_retry = None
|
||||
job.save()
|
||||
messages.success(self.request, _('The sync job has been set to run as soon as possible.'))
|
||||
|
||||
return redirect(self.get_order_url())
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
return HttpResponseNotAllowed(['POST'])
|
||||
|
||||
|
||||
class FailedSyncJobsView(ListView):
|
||||
template_name = 'pretixcontrol/datasync/failed_jobs.html'
|
||||
model = OrderSyncQueue
|
||||
context_object_name = 'queue_items'
|
||||
paginate_by = 100
|
||||
ordering = ('triggered',)
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(
|
||||
Q(need_manual_retry__isnull=False)
|
||||
| Q(failed_attempts__gt=0)
|
||||
).select_related(
|
||||
'order'
|
||||
)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
items = self.get_queryset().filter(pk__in=request.POST.getlist('idlist'))
|
||||
|
||||
if self.request.POST.get("action") == "retry":
|
||||
for item in items:
|
||||
item.not_before = now()
|
||||
item.need_manual_retry = None
|
||||
item.save()
|
||||
messages.success(self.request, _('The selected jobs have been set to run as soon as possible.'))
|
||||
elif self.request.POST.get("action") == "cancel":
|
||||
items.delete()
|
||||
messages.success(self.request, _('The selected jobs have been canceled.'))
|
||||
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
|
||||
class GlobalFailedSyncJobsView(AdministratorPermissionRequiredMixin, FailedSyncJobsView):
|
||||
pass
|
||||
|
||||
|
||||
class OrganizerFailedSyncJobsView(OrganizerPermissionRequiredMixin, FailedSyncJobsView):
|
||||
permission = "can_change_organizer_settings"
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(
|
||||
event__organizer=self.request.organizer
|
||||
)
|
||||
|
||||
|
||||
class EventFailedSyncJobsView(EventPermissionRequiredMixin, FailedSyncJobsView):
|
||||
permission = "can_change_event_settings"
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(
|
||||
event=self.request.event
|
||||
)
|
||||
@@ -83,6 +83,7 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund,
|
||||
PrintLog,
|
||||
)
|
||||
from pretix.base.models.tax import ask_for_vat_id
|
||||
from pretix.base.payment import PaymentException
|
||||
@@ -597,6 +598,7 @@ class OrderDetail(OrderView):
|
||||
'item__questions', 'issued_gift_cards', 'owned_gift_cards', 'linked_media',
|
||||
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
|
||||
Prefetch('all_checkins', queryset=Checkin.all.select_related('list').order_by('datetime')),
|
||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device').order_by('datetime')),
|
||||
).order_by('positionid')
|
||||
|
||||
positions = []
|
||||
|
||||
@@ -49,7 +49,7 @@ from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django.views import View
|
||||
from django.views.generic import CreateView, FormView, ListView, UpdateView
|
||||
@@ -768,8 +768,15 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Asyn
|
||||
ctx['time_formset'] = self.time_formset
|
||||
|
||||
tf = get_format('TIME_INPUT_FORMATS')[0]
|
||||
ctx['time_admission_sample'] = time(8, 30, 0).strftime(tf)
|
||||
ctx['time_begin_sample'] = time(9, 0, 0).strftime(tf)
|
||||
ctx['time_end_sample'] = time(18, 0, 0).strftime(tf)
|
||||
|
||||
df = get_format('DATETIME_INPUT_FORMATS')[0]
|
||||
ctx['datetime_sample'] = now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
from babel.core import Locale
|
||||
from django.core.cache import cache
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext_noop
|
||||
from django_countries import Countries, collator
|
||||
from django_countries.fields import CountryField
|
||||
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
|
||||
@@ -118,3 +119,14 @@ def get_phone_prefixes_sorted_and_localized():
|
||||
_cached_phone_prefixes[cache_key] = val
|
||||
cache.set(cache_key, val, 3600 * 24 * 30)
|
||||
return val
|
||||
|
||||
|
||||
custom_translations = [
|
||||
# Hotfix to allow pretix to provide custom translations until
|
||||
# https://github.com/SmileyChris/django-countries/pull/471
|
||||
# is merged
|
||||
gettext_noop("Belarus"),
|
||||
gettext_noop("French Guiana"),
|
||||
gettext_noop("North Macedonia"),
|
||||
gettext_noop("Macao"),
|
||||
]
|
||||
|
||||
@@ -32,7 +32,6 @@ from django.conf import settings
|
||||
from django.utils import translation
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.translation import to_locale
|
||||
from django.utils.translation.trans_real import TranslationCatalog
|
||||
|
||||
date_conversion_to_moment = {
|
||||
'%a': 'ddd',
|
||||
@@ -175,7 +174,7 @@ def get_language_score(locale):
|
||||
|
||||
Note that there is no valid score for "en", since it's technically not "translated".
|
||||
"""
|
||||
catalog = None
|
||||
catalog = {}
|
||||
app_configs = reversed(apps.get_app_configs())
|
||||
|
||||
for app in app_configs:
|
||||
@@ -198,10 +197,15 @@ def get_language_score(locale):
|
||||
)
|
||||
except:
|
||||
continue
|
||||
if catalog is None:
|
||||
catalog = TranslationCatalog(translation)
|
||||
else:
|
||||
catalog.update(translation)
|
||||
|
||||
catalog.update(translation._catalog.copy())
|
||||
|
||||
# Also add fallback catalog (e.g. es for es-419, de for de-informal, …)
|
||||
while translation._fallback:
|
||||
if not locale.startswith(translation._fallback.info().get("language", "XX")):
|
||||
break
|
||||
translation = translation._fallback
|
||||
catalog.update(translation._catalog.copy())
|
||||
|
||||
# Add pretix' main translation folder as well as installation-specific translation folders
|
||||
for localedir in reversed(settings.LOCALE_PATHS):
|
||||
@@ -214,10 +218,13 @@ def get_language_score(locale):
|
||||
)
|
||||
except:
|
||||
continue
|
||||
if catalog is None:
|
||||
catalog = TranslationCatalog(translation)
|
||||
else:
|
||||
catalog.update(translation)
|
||||
catalog.update(translation._catalog.copy())
|
||||
|
||||
while translation._fallback:
|
||||
if not locale.startswith(translation._fallback.info().get("language", "XX")):
|
||||
break
|
||||
translation = translation._fallback
|
||||
catalog.update(translation._catalog.copy())
|
||||
|
||||
if not catalog:
|
||||
score = 1
|
||||
|
||||
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-06-26 09:11+0000\n"
|
||||
"POT-Creation-Date: 2025-08-04 12:44+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"
|
||||
@@ -557,48 +557,48 @@ msgstr ""
|
||||
msgid "The PDF background file could not be loaded for the following reason:"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:903
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:904
|
||||
msgid "Group of objects"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:908
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:909
|
||||
msgid "Text object (deprecated)"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:910
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:911
|
||||
msgid "Text box"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:912
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:913
|
||||
msgid "Barcode area"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:914
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:915
|
||||
msgid "Image area"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:916
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:917
|
||||
msgid "Powered by pretix"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:918
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:919
|
||||
msgid "Object"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:922
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:923
|
||||
msgid "Ticket design"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1258
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1292
|
||||
msgid "Saving failed."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1327
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1378
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1412
|
||||
msgid "Error while uploading your PDF file, please try again."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1395
|
||||
msgid "Do you really want to leave the editor without saving your changes?"
|
||||
msgstr ""
|
||||
|
||||
@@ -630,40 +630,40 @@ msgid ""
|
||||
"will be impacted."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:416
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:417
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:437
|
||||
msgid "Search query"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:434
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:439
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:440
|
||||
msgid "Selected only"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:811
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:812
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:814
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:815
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:972
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:973
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1012
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1013
|
||||
msgid "Click to close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1093
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1094
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr ""
|
||||
|
||||
|
||||
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-06-26 09:11+0000\n"
|
||||
"POT-Creation-Date: 2025-08-04 12:44+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/"
|
||||
@@ -587,52 +587,52 @@ msgstr "QR الدخول"
|
||||
msgid "The PDF background file could not be loaded for the following reason:"
|
||||
msgstr "لا يمكن تحميل ملف PDF الخلفية للأسباب التالية:"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:903
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:904
|
||||
msgid "Group of objects"
|
||||
msgstr "مجموعة من العناصر"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:908
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:909
|
||||
#, fuzzy
|
||||
#| msgid "Text object"
|
||||
msgid "Text object (deprecated)"
|
||||
msgstr "عنصر نص"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:910
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:911
|
||||
#, fuzzy
|
||||
#| msgid "Text object"
|
||||
msgid "Text box"
|
||||
msgstr "عنصر نص"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:912
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:913
|
||||
msgid "Barcode area"
|
||||
msgstr "منطقة باركود"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:914
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:915
|
||||
msgid "Image area"
|
||||
msgstr "منطقة صورة"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:916
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:917
|
||||
msgid "Powered by pretix"
|
||||
msgstr "مدعوم من pretix"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:918
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:919
|
||||
msgid "Object"
|
||||
msgstr "عنصر"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:922
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:923
|
||||
msgid "Ticket design"
|
||||
msgstr "تصميم التذكرة"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1258
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1292
|
||||
msgid "Saving failed."
|
||||
msgstr "فشلت عملية الحفظ."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1327
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1378
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1412
|
||||
msgid "Error while uploading your PDF file, please try again."
|
||||
msgstr "حصل خطأ أثناء رفع ملف PDF الخاص بك، يرجى المحاولة مرة أخرى."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1395
|
||||
msgid "Do you really want to leave the editor without saving your changes?"
|
||||
msgstr "هل تريد أن تغادر المحرر دون حفظ التعديلات؟"
|
||||
|
||||
@@ -668,40 +668,40 @@ msgid ""
|
||||
"will be impacted."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:416
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:417
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:437
|
||||
msgid "Search query"
|
||||
msgstr "البحث في الاستفسارات"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:434
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
msgid "All"
|
||||
msgstr "الكل"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
msgid "None"
|
||||
msgstr "لا شيء"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:439
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:440
|
||||
msgid "Selected only"
|
||||
msgstr "المختارة فقط"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:811
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:812
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:814
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:815
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:972
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:973
|
||||
msgid "Use a different name internally"
|
||||
msgstr "قم باستخدم اسم مختلف داخليا"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1012
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1013
|
||||
msgid "Click to close"
|
||||
msgstr "اضغط لاغلاق الصفحة"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1093
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1094
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr "لم تقم بحفظ التعديلات!"
|
||||
|
||||
|
||||
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-06-26 09:11+0000\n"
|
||||
"POT-Creation-Date: 2025-08-04 12:44+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"
|
||||
@@ -557,48 +557,48 @@ msgstr ""
|
||||
msgid "The PDF background file could not be loaded for the following reason:"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:903
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:904
|
||||
msgid "Group of objects"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:908
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:909
|
||||
msgid "Text object (deprecated)"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:910
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:911
|
||||
msgid "Text box"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:912
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:913
|
||||
msgid "Barcode area"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:914
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:915
|
||||
msgid "Image area"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:916
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:917
|
||||
msgid "Powered by pretix"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:918
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:919
|
||||
msgid "Object"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:922
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:923
|
||||
msgid "Ticket design"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1258
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1292
|
||||
msgid "Saving failed."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1327
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1378
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1412
|
||||
msgid "Error while uploading your PDF file, please try again."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1395
|
||||
msgid "Do you really want to leave the editor without saving your changes?"
|
||||
msgstr ""
|
||||
|
||||
@@ -630,40 +630,40 @@ msgid ""
|
||||
"will be impacted."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:416
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:417
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:437
|
||||
msgid "Search query"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:434
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:439
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:440
|
||||
msgid "Selected only"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:811
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:812
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:814
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:815
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:972
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:973
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1012
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1013
|
||||
msgid "Click to close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1093
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1094
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr ""
|
||||
|
||||
|
||||
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-06-26 09:11+0000\n"
|
||||
"POT-Creation-Date: 2025-08-04 12:44+0000\n"
|
||||
"PO-Revision-Date: 2020-12-19 07:00+0000\n"
|
||||
"Last-Translator: albert <albert.serra.monner@gmail.com>\n"
|
||||
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
@@ -560,48 +560,48 @@ msgstr ""
|
||||
msgid "The PDF background file could not be loaded for the following reason:"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:903
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:904
|
||||
msgid "Group of objects"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:908
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:909
|
||||
msgid "Text object (deprecated)"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:910
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:911
|
||||
msgid "Text box"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:912
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:913
|
||||
msgid "Barcode area"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:914
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:915
|
||||
msgid "Image area"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:916
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:917
|
||||
msgid "Powered by pretix"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:918
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:919
|
||||
msgid "Object"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:922
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:923
|
||||
msgid "Ticket design"
|
||||
msgstr "Disseny del tiquet"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1258
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1292
|
||||
msgid "Saving failed."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1327
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1378
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1412
|
||||
msgid "Error while uploading your PDF file, please try again."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1395
|
||||
msgid "Do you really want to leave the editor without saving your changes?"
|
||||
msgstr ""
|
||||
|
||||
@@ -633,40 +633,40 @@ msgid ""
|
||||
"will be impacted."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:416
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:417
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:437
|
||||
msgid "Search query"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:434
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:439
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:440
|
||||
msgid "Selected only"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:811
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:812
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:814
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:815
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:972
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:973
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1012
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1013
|
||||
msgid "Click to close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1093
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1094
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr ""
|
||||
|
||||
|
||||
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-06-26 09:11+0000\n"
|
||||
"POT-Creation-Date: 2025-08-04 12:44+0000\n"
|
||||
"PO-Revision-Date: 2025-05-16 17:00+0000\n"
|
||||
"Last-Translator: David <davemachala@gmail.com>\n"
|
||||
"Language-Team: Czech <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -578,52 +578,52 @@ msgstr "Check-in QR kód"
|
||||
msgid "The PDF background file could not be loaded for the following reason:"
|
||||
msgstr "Pozadí PDF nemohl být načten:"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:903
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:904
|
||||
msgid "Group of objects"
|
||||
msgstr "Skupina objektů"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:908
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:909
|
||||
#, fuzzy
|
||||
#| msgid "Text object"
|
||||
msgid "Text object (deprecated)"
|
||||
msgstr "Textový objekt"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:910
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:911
|
||||
#, fuzzy
|
||||
#| msgid "Text object"
|
||||
msgid "Text box"
|
||||
msgstr "Textový objekt"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:912
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:913
|
||||
msgid "Barcode area"
|
||||
msgstr "Oblast s QR kódem"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:914
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:915
|
||||
msgid "Image area"
|
||||
msgstr "Oblast obrazu"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:916
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:917
|
||||
msgid "Powered by pretix"
|
||||
msgstr "Poháněno společností pretix"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:918
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:919
|
||||
msgid "Object"
|
||||
msgstr "Objekt"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:922
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:923
|
||||
msgid "Ticket design"
|
||||
msgstr "Design vstupenky"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1258
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1292
|
||||
msgid "Saving failed."
|
||||
msgstr "Uložení se nepodařilo."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1327
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1378
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1412
|
||||
msgid "Error while uploading your PDF file, please try again."
|
||||
msgstr "Při nahrávání souboru PDF došlo k problému, zkuste to prosím znovu."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1395
|
||||
msgid "Do you really want to leave the editor without saving your changes?"
|
||||
msgstr "Opravdu chcete opustit editor bez uložení změn?"
|
||||
|
||||
@@ -660,40 +660,40 @@ msgid ""
|
||||
"will be impacted."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:416
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:417
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:437
|
||||
msgid "Search query"
|
||||
msgstr "Hledaný výraz"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:434
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
msgid "All"
|
||||
msgstr "Všechny"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
msgid "None"
|
||||
msgstr "Žádný"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:439
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:440
|
||||
msgid "Selected only"
|
||||
msgstr "Pouze vybrané"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:811
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:812
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:814
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:815
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:972
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:973
|
||||
msgid "Use a different name internally"
|
||||
msgstr "Interně používat jiný název"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1012
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1013
|
||||
msgid "Click to close"
|
||||
msgstr "Kliknutím zavřete"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1093
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1094
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr "Máte neuložené změny!"
|
||||
|
||||
|
||||
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-06-26 09:11+0000\n"
|
||||
"POT-Creation-Date: 2025-08-04 12:44+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"
|
||||
@@ -558,48 +558,48 @@ msgstr ""
|
||||
msgid "The PDF background file could not be loaded for the following reason:"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:903
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:904
|
||||
msgid "Group of objects"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:908
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:909
|
||||
msgid "Text object (deprecated)"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:910
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:911
|
||||
msgid "Text box"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:912
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:913
|
||||
msgid "Barcode area"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:914
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:915
|
||||
msgid "Image area"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:916
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:917
|
||||
msgid "Powered by pretix"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:918
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:919
|
||||
msgid "Object"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:922
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:923
|
||||
msgid "Ticket design"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1258
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1292
|
||||
msgid "Saving failed."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1327
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1378
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1412
|
||||
msgid "Error while uploading your PDF file, please try again."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1395
|
||||
msgid "Do you really want to leave the editor without saving your changes?"
|
||||
msgstr ""
|
||||
|
||||
@@ -631,40 +631,40 @@ msgid ""
|
||||
"will be impacted."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:416
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:417
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:437
|
||||
msgid "Search query"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:434
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:439
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:440
|
||||
msgid "Selected only"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:811
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:812
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:814
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:815
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:972
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:973
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1012
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1013
|
||||
msgid "Click to close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1093
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1094
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-06-26 09:11+0000\n"
|
||||
"POT-Creation-Date: 2025-08-04 12:44+0000\n"
|
||||
"PO-Revision-Date: 2024-07-10 15:00+0000\n"
|
||||
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n"
|
||||
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -600,54 +600,54 @@ msgstr "Check-in QR"
|
||||
msgid "The PDF background file could not be loaded for the following reason:"
|
||||
msgstr "Baggrunds-pdf'en kunne ikke hentes af følgende grund:"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:903
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:904
|
||||
msgid "Group of objects"
|
||||
msgstr "Gruppe af objekter"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:908
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:909
|
||||
#, fuzzy
|
||||
#| msgid "Text object"
|
||||
msgid "Text object (deprecated)"
|
||||
msgstr "Tekstobjekt"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:910
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:911
|
||||
#, fuzzy
|
||||
#| msgid "Text object"
|
||||
msgid "Text box"
|
||||
msgstr "Tekstobjekt"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:912
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:913
|
||||
msgid "Barcode area"
|
||||
msgstr "QR-kode-område"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:914
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:915
|
||||
#, fuzzy
|
||||
#| msgid "Barcode area"
|
||||
msgid "Image area"
|
||||
msgstr "QR-kode-område"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:916
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:917
|
||||
msgid "Powered by pretix"
|
||||
msgstr "Drevet af pretix"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:918
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:919
|
||||
msgid "Object"
|
||||
msgstr "Objekt"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:922
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:923
|
||||
msgid "Ticket design"
|
||||
msgstr "Billetdesign"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1258
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1292
|
||||
msgid "Saving failed."
|
||||
msgstr "Gem fejlede."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1327
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1378
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1412
|
||||
msgid "Error while uploading your PDF file, please try again."
|
||||
msgstr "Fejl under upload af pdf. Prøv venligt igen."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1395
|
||||
msgid "Do you really want to leave the editor without saving your changes?"
|
||||
msgstr ""
|
||||
"Er du sikker på at du vil forlade editoren uden at gemme dine ændringer?"
|
||||
@@ -680,40 +680,40 @@ msgid ""
|
||||
"will be impacted."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:416
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:417
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:437
|
||||
msgid "Search query"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:434
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
msgid "All"
|
||||
msgstr "Alle"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
msgid "None"
|
||||
msgstr "Ingen"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:439
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:440
|
||||
msgid "Selected only"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:811
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:812
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:814
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:815
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:972
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:973
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1012
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1013
|
||||
msgid "Click to close"
|
||||
msgstr "Klik for at lukke"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1093
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1094
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr "Du har ændringer, der ikke er gemt!"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-06-26 09:11+0000\n"
|
||||
"POT-Creation-Date: 2025-08-04 12:44+0000\n"
|
||||
"PO-Revision-Date: 2025-05-30 11:08+0000\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -573,49 +573,49 @@ msgstr "Check-in-QR-Code"
|
||||
msgid "The PDF background file could not be loaded for the following reason:"
|
||||
msgstr "Die Hintergrund-PDF-Datei konnte nicht geladen werden:"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:903
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:904
|
||||
msgid "Group of objects"
|
||||
msgstr "Gruppe von Objekten"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:908
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:909
|
||||
msgid "Text object (deprecated)"
|
||||
msgstr "Text-Objekt (veraltet)"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:910
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:911
|
||||
msgid "Text box"
|
||||
msgstr "Textbox"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:912
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:913
|
||||
msgid "Barcode area"
|
||||
msgstr "QR-Code-Bereich"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:914
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:915
|
||||
msgid "Image area"
|
||||
msgstr "Bildbereich"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:916
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:917
|
||||
msgid "Powered by pretix"
|
||||
msgstr "Event-Ticketshop von pretix"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:918
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:919
|
||||
msgid "Object"
|
||||
msgstr "Objekt"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:922
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:923
|
||||
msgid "Ticket design"
|
||||
msgstr "Ticket-Design"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1258
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1292
|
||||
msgid "Saving failed."
|
||||
msgstr "Speichern fehlgeschlagen."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1327
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1378
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1412
|
||||
msgid "Error while uploading your PDF file, please try again."
|
||||
msgstr ""
|
||||
"Es gab ein Problem beim Hochladen der PDF-Datei, bitte erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1361
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1395
|
||||
msgid "Do you really want to leave the editor without saving your changes?"
|
||||
msgstr ""
|
||||
"Möchten Sie den Editor wirklich schließen ohne Ihre Änderungen zu speichern?"
|
||||
@@ -653,40 +653,40 @@ msgstr ""
|
||||
"Diese Farbe hat keinen ausreichenden Kontrast zu weiß. Die Barrierefreiheit "
|
||||
"der Seite ist eingeschränkt."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:416
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:417
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:437
|
||||
msgid "Search query"
|
||||
msgstr "Suchbegriff"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:434
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
msgid "All"
|
||||
msgstr "Alle"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:435
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:436
|
||||
msgid "None"
|
||||
msgstr "Keine"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:439
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:440
|
||||
msgid "Selected only"
|
||||
msgstr "Nur ausgewählte"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:811
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:812
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr "Geben Sie eine Seitenzahl zwischen 1 und %(max)s ein."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:814
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:815
|
||||
msgid "Invalid page number."
|
||||
msgstr "Ungültige Seitenzahl."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:972
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:973
|
||||
msgid "Use a different name internally"
|
||||
msgstr "Intern einen anderen Namen verwenden"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1012
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1013
|
||||
msgid "Click to close"
|
||||
msgstr "Klicken zum Schließen"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1093
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1094
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr "Sie haben ungespeicherte Änderungen!"
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Apps
|
||||
APM
|
||||
as
|
||||
Aufbuchungen
|
||||
aufeinanderfolgenden
|
||||
Aufteilungsliste
|
||||
aufzubuchen
|
||||
auschecken
|
||||
@@ -44,6 +45,7 @@ Bcc
|
||||
BCC
|
||||
Beispielevent
|
||||
Beispielproduktvariantenbeschreibung
|
||||
Belarus
|
||||
Benachrichtigungs
|
||||
Benachrichtigungsart
|
||||
Benachrichtigungsarten
|
||||
@@ -146,6 +148,8 @@ GiroCode
|
||||
giropay
|
||||
GPL
|
||||
Grants
|
||||
Gruppierungswert
|
||||
Gruppierungsspalte
|
||||
Guide
|
||||
Gutscheineinlöser
|
||||
herunterscrollen
|
||||
@@ -194,6 +198,7 @@ Logindaten
|
||||
Lösch
|
||||
loszulegen
|
||||
Ltd
|
||||
Macau
|
||||
max
|
||||
MariaDB
|
||||
MapQuest
|
||||
@@ -318,6 +323,7 @@ Stornobedingungen
|
||||
Stornobeleg
|
||||
Stornodatum
|
||||
Stornogebühr
|
||||
Stornogebühren
|
||||
Stornos
|
||||
Strg
|
||||
Stripe
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user