Compare commits

..

1 Commits

Author SHA1 Message Date
Mira
6944d28083 Pass widget_data to new tab (Z#23176995)
This allows pre-filled questions and other widget configuration to be used even if Safari third-party tracking prevention is enabled
2025-03-06 12:54:26 +01:00
484 changed files with 245930 additions and 330177 deletions

View File

@@ -1,5 +1,5 @@
{
"contributors": "https://crm.rami.io/cla/check/?project=pretix&checkContributor=",
"message": "Hey there! :) Thank you very much for offering a contribution to pretix! For legal reasons, we need you to sign a Contributor License Agreement in order to be able to merge the code. Sorry for the hassle :( Please download the agreement from https://pretix.eu/about/en/cla and send a signed copy to support@pretix.eu. Feel free to also contact us there or via comments here if you have any questions!\n\nFeel free to ignore me on documentation changes, translations, and trivial PRs like typo fixes (and similar single-line changes) we can merge these without a CLA as well.",
"message": "Hey there! :) Thank you very much for offering a contribution to pretix! For legal reasons, we need you to sign a Contributor License Agreement in order to be able to merge the code. Sorry for the hassle :( Please download the agreement from https://pretix.eu/about/en/cla and send a signed copy to support@pretix.eu. Feel free to also contact us there or via comments here if you have any questions!",
"label": "cla-signed"
}

View File

@@ -8,7 +8,6 @@ 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

View File

@@ -2,11 +2,11 @@
## Reporting a vulnerability
If you discover a vulnerability with our software or server systems, please report it to us in private. Do not to attempt to harm our users, customer's data or our system's availability when looking for vulnerabilities.
If you discover a vulnerability with our software or server systems, please report it to us in private. Do not to attempt to harm our users, customer's data or our system's availability when looking for vulneratbilities.
Please contact us at security@pretix.eu with full details and steps to reproduce and allow reasonable time for us to resolve the issue before publishing your findings. If you wish to encrypt your email, you can find our GPG key [here](https://pretix.eu/.well-known/security@pretix.eu.asc).
Please also see our [Responsible disclosure policy](https://docs.pretix.eu/trust/security/disclosure/).
We're not large enough to run a formal bug bounty program, but if you find a serious vulnerability in our service, we will find a way to show our gratitude.
## Version support
@@ -18,5 +18,3 @@ subscribe to our [newsletter](https://pretix.eu/about/en/blog/) in the "News abo
category, we will also send you an email on security issues.
Past security issues are listed [on our website](https://pretix.eu/about/en/security).
Please also see our [Release cycle](https://docs.pretix.eu/trust/lifecycle/release-cycle/) documentation.

View File

@@ -48,6 +48,11 @@ seat objects The assigned se
└ seat_guid string Identifier of the seat within the seating plan
===================================== ========================== =======================================================
.. versionchanged:: 4.14
This ``is_bundled`` attribute has been added and the cart creation endpoints have been updated.
Cart position endpoints
-----------------------

View File

@@ -23,7 +23,7 @@ Certificate download
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/certificate/ HTTP/1.1
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/certificate/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
@@ -38,7 +38,7 @@ Certificate download
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/certificate/?result=1f550651-ae7b-4911-a76c-2be8f348aaa5 HTTP/1.1
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/certificate/?result=1f550651-ae7b-4911-a76c-2be8f348aaa5 HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript

View File

@@ -9,6 +9,14 @@ This page describes special APIs built for ticket scanning apps. For managing ch
please also see :ref:`rest-checkinlists`. The check-in list API also contains endpoints to obtain statistics or log
failed scans.
.. versionchanged:: 4.12
The endpoints listed on this page have been added.
.. versionchanged:: 4.18
The ``source_type`` parameter has been added.
.. _`rest-checkin-redeem`:
Checking a ticket in
@@ -46,11 +54,6 @@ Checking a ticket in
this request twice with the same nonce, the second request will also succeed but will always
create only one check-in object even when the previous request was successful as well. This
allows for a certain level of idempotency and enables you to re-try after a connection failure.
:<json boolean use_order_locale: Specifies that pretix should use the customer's language (``locale`` field from the
order) when building texts (currently only the ``reason_explanation`` response field).
Defaults to ``false`` in which case the server will determine the language (currently
the event default language, might change in the future with support for the
``Accept-Language`` header).
:>json string status: ``"ok"``, ``"incomplete"``, or ``"error"``
:>json string reason: Reason code, only set on status ``"error"``, see below for possible values.
:>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null.
@@ -59,9 +62,7 @@ Checking a ticket in
will only include check-ins for the selected list. (2) An additional boolean property
``require_attention`` will inform you whether either the order or the item have the
``checkin_attention`` flag set. (3) If ``attendee_name`` is empty, it may automatically fall
back to values from a parent product or from invoice addresses. (4) Additional properties
``order__status``, ``order__valid_if_pending``, ``order__require_approval``, and
``order__locale`` are included with details form the order for convenience.
back to values from a parent product or from invoice addresses.
:>json boolean require_attention: Whether or not the ``require_attention`` flag is set on the item or order.
:>json list checkin_texts: List of additional texts to show to the user.
:>json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found),
@@ -359,65 +360,3 @@ Performing a ticket search
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or check-in list does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested check-in list does not exist.
.. _`rest-checkin-annul`:
Annulment of a check-in
-----------------------
.. http:post:: /api/v1/organizers/(organizer)/checkinrpc/annul/
If a check-in was made in error and the person was not let in, it can be annulled. We do not recommend this to be used
in case of manual check-ins or user interfaces because it is too prone for human errors. It is mostly intended for
automated entry systems like a turnstile or automated door, where the check-in is first created, then the door is
opened, and then the check-in may be annulled if the system knows that the turnstile did not turn or was out of
order.
This endpoint supports passing multiple check-in lists for the context of a multi-event scan. However, each
check-in list passed needs to be from a distinct event.
Check-ins created by a device can only be annulled by the same device. The datetime of annulment may not be more than
15 minutes after the datetime of check-in (value subject to change).
A status code of 404 is returned if no check-in was found for the given nonce. A status code of 400 is returned when
multiple check-ins match the nonce, the input is invalid in another way, the annulment is made from the wrong device,
the check-in is already in an annulled or failed state, or the datetime constraint is not valid.
:<json string nonce: ``nonce`` value of the original check-in.
:<json array lists: List of check-in list IDs to search on. No two check-in lists may be from the same event.
:<json datetime datetime: Specifies the client-side datetime of the annulment. If not supplied, the current time will be used.
:<json string error_explanation: A human-readable description of why the check-in was annulled (optional).
:>json string status: ``"ok"``
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/checkinrpc/annul/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
{
"lists": [1],
"nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
"error_explanation": "Turnstile did not turn"
}
**Example successful response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"status": "ok",
}
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 400: Invalid or incomplete request, see above
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested nonce does not exist.

View File

@@ -40,6 +40,10 @@ ignore_in_statistics boolean If ``true``, ch
consider_tickets_used boolean If ``true`` (default), tickets checked in on this list will be considered "used" by other functionality, i.e. when checking if they can still be canceled.
===================================== ========================== =======================================================
.. versionchanged:: 4.12
The ``addon_match`` attribute has been added.
.. versionchanged:: 2023.9
The ``ignore_in_statistics`` and ``consider_tickets_used`` attributes have been added.

View File

@@ -34,6 +34,12 @@ password string Can only be set
not be included in any responses.
===================================== ========================== =======================================================
.. versionadded:: 4.0
.. versionchanged:: 4.3
Passwords can now be set through the API during customer creation.
.. versionchanged:: 2024.3
The attribute ``phone`` has been added.

View File

@@ -35,10 +35,6 @@ subevent_mode strings Determines h
``"same"`` (discount is only applied for groups within
the same date), or ``"distinct"`` (discount is only applied
for groups with no two same dates).
subevent_date_from datetime The first date time of a subevent to which this discount can be applied
(or ``null``). Ignored in non-series events.
subevent_date_until datetime The last date time of a subevent to which this discount can be applied
(or ``null``). Ignored in non-series events.
condition_all_products boolean If ``true``, the discount condition applies to all items.
condition_limit_products list of integers If ``condition_all_products`` is not set, this is a list
of internal item IDs that the discount condition applies to.
@@ -109,8 +105,6 @@ Endpoints
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
@@ -169,8 +163,6 @@ Endpoints
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
@@ -215,8 +207,6 @@ Endpoints
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
@@ -250,8 +240,6 @@ Endpoints
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
@@ -314,8 +302,6 @@ Endpoints
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,

View File

@@ -61,6 +61,25 @@ public_url string The public, cus
Endpoints
---------
.. versionchanged:: 4.0
The ``clone_from`` parameter has been added to the event creation endpoint.
.. versionchanged:: 4.1
The ``with_availability_for`` parameter has been added.
The ``search`` query parameter has been added to filter events by their slug, name, or location in any language.
.. versionchanged:: 4.17
The ``public_url`` field has been added.
.. versionchanged:: 5.0
The ``date_from_before``, ``date_from_after``, ``date_to_before``, and ``date_to_after`` query parameters have been
added.
.. http:get:: /api/v1/organizers/(organizer)/events/
Returns a list of all events within a given organizer the authenticated user/token has access to.
@@ -611,6 +630,10 @@ organizer level.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. versionchanged:: 4.18
The ``readonly`` flag has been added.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/settings/
Updates event settings. Note that ``PUT`` is not allowed here, only ``PATCH``.

View File

@@ -349,45 +349,6 @@ 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.

View File

@@ -47,6 +47,11 @@ acceptor string Organizer slug
this field was added.)
===================================== ========================== =======================================================
.. versionchanged:: 4.20
The ``owner_ticket`` and ``issuer`` attributes of the gift card and the ``info`` and ``acceptor`` attributes of the
gift card transaction resource have been added.
Endpoints
---------

View File

@@ -25,7 +25,6 @@ at :ref:`plugin-docs`.
seats
orders
invoices
transactions
vouchers
discounts
checkin
@@ -55,7 +54,6 @@ at :ref:`plugin-docs`.
digital
exhibitors
imported_secrets
offlinesales
shipping
billing_invoices
billing_var

View File

@@ -1,5 +1,3 @@
.. _rest-invoices:
Invoices
========
@@ -113,6 +111,18 @@ internal_reference string Customer's refe
===================================== ========================== =======================================================
.. versionchanged:: 4.1
The attributes ``fee_type`` and ``fee_internal_type`` have been added.
.. versionchanged:: 4.1
The attribute ``lines.event_location`` has been added.
.. versionchanged:: 4.6
The attribute ``lines.subevent`` has been added.
.. versionchanged:: 2023.8
The ``event`` attribute has been added. The organizer-level endpoint has been added.

View File

@@ -64,6 +64,10 @@ hide_without_voucher boolean If ``true``, th
meta_data object Values set for event-specific meta data parameters.
===================================== ========================== =======================================================
.. versionchanged:: 4.16
The ``meta_data`` and ``checkin_attention`` attributes have been added.
.. versionchanged:: 2023.10
The ``free_price_suggestion`` attribute has been added.

View File

@@ -211,6 +211,28 @@ bundles list of objects Definition of
meta_data object Values set for event-specific meta data parameters.
======================================= ========================== =======================================================
.. versionchanged:: 4.0
The attributes ``require_membership``, ``require_membership_types``, ``grant_membership_type``, ``grant_membership_duration_like_event``,
``grant_membership_duration_days`` and ``grant_membership_duration_months`` have been added.
.. versionchanged:: 4.4
The attributes ``require_membership_hidden`` attribute has been added.
.. versionchanged:: 4.16
The ``variations[x].meta_data`` and ``variations[x].checkin_attention`` attributes have been added.
The ``personalized`` attribute has been added.
.. versionchanged:: 4.17
The ``validity_*`` attributes have been added.
.. versionchanged:: 4.18
The ``media_policy`` and ``media_type`` attributes have been added.
.. versionchanged:: 2023.10
The ``checkin_text`` and ``variations[x].checkin_text`` attributes have been added.

View File

@@ -1,219 +0,0 @@
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.

View File

@@ -114,6 +114,34 @@ plugin_data object Additional data
===================================== ========================== =======================================================
.. versionchanged:: 4.0
The ``customer`` attribute has been added.
.. versionchanged:: 4.1
The ``custom_followup_at`` attribute has been added.
.. versionchanged:: 4.4
The ``item`` and ``variation`` query parameters have been added.
.. versionchanged:: 4.6
The ``subevent`` query parameters has been added.
.. versionchanged:: 4.8
The ``order.fees.id`` attribute has been added.
.. versionchanged:: 4.15
The ``include`` query parameter has been added.
.. versionchanged:: 4.16
The ``valid_if_pending`` attribute has been added.
.. versionchanged:: 2023.8
The ``event`` attribute has been added. The organizer-level endpoint has been added.
@@ -232,6 +260,10 @@ pdf_data object Data object req
plugin_data object Additional data added by plugins.
===================================== ========================== =======================================================
.. versionchanged:: 4.16
The attributes ``blocked``, ``valid_from`` and ``valid_until`` have been added.
.. versionchanged:: 2024.9
The attribute ``print_logs`` has been added.
@@ -724,6 +756,10 @@ Fetching individual orders
Order ticket download
---------------------
.. versionchanged:: 4.10
The API now supports ticket downloads for pending orders if allowed by the event settings.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/download/(output)/
Download tickets for an order, identified by its order code. Depending on the chosen output, the response might
@@ -1059,10 +1095,9 @@ Creating orders
prices. Note that this will not include other fees and is calculated once during order generation and will not
be respected automatically when the order changes later.)
* ``_split_taxes_like_products`` (Optional convenience flag. If set to ``true``, your ``tax_rule`` will be ignored
and the fee will be taxed like the products in the order *unless* the total amount of the positions is zero.
If the products have multiple tax rates, multiple fees will be generated with weights adjusted to the net price
of the products. Note that this will be calculated once during order generation and is not respected automatically
when the order changes later.)
and the fee will be taxed like the products in the order. If the products have multiple tax rates, multiple fees
will be generated with weights adjusted to the net price of the products. Note that this will be calculated once
during order generation and is not respected automatically when the order changes later.)
* ``force`` (optional). If set to ``true``, quotas will be ignored.
* ``send_email`` (optional). If set to ``true``, the same emails will be sent as for a regular order, regardless of
@@ -1797,6 +1832,10 @@ Fetching individual positions
Order position ticket download
------------------------------
.. versionchanged:: 4.10
The API now supports ticket downloads for pending orders if allowed by the event settings.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/download/(output)/
Download tickets for one order position, identified by its internal ID.
@@ -1849,6 +1888,15 @@ Order position ticket download
Manipulating individual positions
---------------------------------
.. versionchanged:: 4.8
The ``PATCH`` method now supports changing items, variations, subevents, seats, prices, and tax rules.
The ``POST`` endpoint to add individual positions has been added.
.. versionadded:: 4.16
The endpoints to manage blocks have been added.
.. versionchanged:: 2024.9
The API now supports logging ticket and badge prints.
@@ -1895,14 +1943,9 @@ Manipulating individual positions
* ``valid_until``
* ``secret``
Changing parameters such as ``item`` or ``price`` will **not** automatically trigger creation of a new invoice,
you need to take care of that yourself.
Changing ``secret`` does not cause a new PDF ticket to be sent to the customer, nor does it cause the old secret
to be added to the revocation list, even if your ticket generator uses one.
**Example request**:
.. sourcecode:: http
@@ -1926,7 +1969,6 @@ Manipulating individual positions
(Full order position resource, see above.)
:query boolean check_quotas: Whether to check quotas before committing item changes, default is ``true``
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param id: The ``id`` field of the order position to update
@@ -2006,7 +2048,6 @@ Manipulating individual positions
(Full order position resource, see above.)
:query boolean check_quotas: Whether to check quotas before creating the new position, default is ``true``
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
@@ -2180,6 +2221,10 @@ multiple changes to an order at once within one transaction. This makes it possi
attendees in an order without running into conflicts. This interface also offers some possibilities not available
otherwise, such as splitting an order or changing fees.
.. versionchanged:: 4.8
This endpoint has been added to the system.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/change/
Performs a change operation on an order. You can supply the following fields:
@@ -2293,7 +2338,6 @@ otherwise, such as splitting an order or changing fees.
(Full order position resource, see above.)
:query boolean check_quotas: Whether to check quotas before patching or creating positions, default is ``true``
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``code`` field of the order to update

View File

@@ -25,6 +25,10 @@ public_url string The public, cus
Endpoints
---------
.. versionchanged:: 4.17
The ``public_url`` field has been added.
.. http:get:: /api/v1/organizers/
Returns a list of all organizers the authenticated user/token has access to.

View File

@@ -28,8 +28,6 @@ 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.
@@ -38,6 +36,11 @@ available_number integer Number of avail
slightly out of date. ``null`` means unlimited.
===================================== ========================== =======================================================
.. versionchanged:: 4.1
The ``with_availability`` query parameter has been added.
Endpoints
---------
@@ -74,8 +77,7 @@ Endpoints
"variations": [1, 4, 5, 7],
"subevent": null,
"close_when_sold_out": false,
"closed": false,
"ignore_for_event_availability": false
"closed": false
}
]
}
@@ -84,8 +86,7 @@ Endpoints
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``.
Default: ``position``
:query integer subevent: Only return quotas of the sub-event with the given ID.
:query integer subevent__in: Only return quotas of sub-events with one of the given IDs (comma-separated).
:query integer items__in: Only return quotas that include a product with one of the given IDs (comma-separated).
:query integer subevent__in: Only return quotas of sub-events with one the given IDs (comma-separated).
:query string with_availability: Set to ``true`` to get availability information. Can lead to increased answer times.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
@@ -121,8 +122,7 @@ Endpoints
"variations": [1, 4, 5, 7],
"subevent": null,
"close_when_sold_out": false,
"closed": false,
"ignore_for_event_availability": false
"closed": false
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -153,8 +153,7 @@ Endpoints
"variations": [1, 4, 5, 7],
"subevent": null,
"close_when_sold_out": false,
"closed": false,
"ignore_for_event_availability": false
"closed": false
}
**Example response**:
@@ -173,8 +172,7 @@ Endpoints
"variations": [1, 4, 5, 7],
"subevent": null,
"close_when_sold_out": false,
"closed": false,
"ignore_for_event_availability": false
"closed": false
}
:param organizer: The ``slug`` field of the organizer of the event/item to create a quota for
@@ -229,8 +227,7 @@ Endpoints
],
"subevent": null,
"close_when_sold_out": false,
"closed": false,
"ignore_for_event_availability": false
"closed": false
}
:param organizer: The ``slug`` field of the organizer to modify

View File

@@ -6,6 +6,10 @@ Data shredders
pretix and it's plugins include a number of data shredders that allow you to clear personal information from the system.
This page shows you how to use these shredders through the API.
.. versionchanged:: 4.12
This feature has been added to the API.
.. warning::
Unlike the user interface, the API will not force you to download tax-relevant data before you delete it.

View File

@@ -59,6 +59,15 @@ seat_category_mapping object An object mappi
last_modified datetime Last modification of this object
===================================== ========================== =======================================================
.. versionchanged:: 4.15
The ``search`` query parameter has been added to filter sub-events by their name or location in any language.
.. versionchanged:: 5.0
The ``date_from_before``, ``date_from_after``, ``date_to_before``, and ``date_to_after`` query parameters have been
added.
.. versionchanged:: 2023.8.0
For the organizer-wide endpoint, the ``search`` query parameter has been modified to filter sub-events by their parent events slug too.
@@ -66,6 +75,10 @@ last_modified datetime Last modificati
Endpoints
---------
.. versionchanged:: 4.1
The ``with_availability_for`` parameter has been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/
Returns a list of all sub-events of an event.

View File

@@ -26,8 +26,6 @@ rate decimal (string) Tax rate in per
code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`.
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
the specified product price
default boolean If ``true`` (default), this is the default tax rate for this event
(there can only be one per event).
eu_reverse_charge boolean **DEPRECATED**. If ``true``, EU reverse charge rules
are applied. Will be ignored if custom rules are set.
Use custom rules instead.
@@ -42,6 +40,10 @@ custom_rules object Dynamic rules s
===================================== ========================== =======================================================
.. versionchanged:: 4.6
The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added.
.. versionchanged:: 2023.6
The ``custom_rules`` attribute has been added.
@@ -50,10 +52,6 @@ custom_rules object Dynamic rules s
The ``code`` attribute has been added.
.. versionchanged:: 2025.4
The ``default`` attribute has been added.
.. _rest-taxcodes:
Tax codes
@@ -117,7 +115,6 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"default": true,
"internal_name": "VAT",
"code": "S/standard",
"rate": "19.00",
@@ -160,7 +157,6 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"default": true,
"internal_name": "VAT",
"code": "S/standard",
"rate": "19.00",
@@ -211,7 +207,6 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"default": false,
"internal_name": "VAT",
"code": "S/standard",
"rate": "19.00",

View File

@@ -39,6 +39,10 @@ can_change_vouchers boolean
can_checkin_orders boolean
===================================== ========================== =======================================================
.. versionchanged:: 4.18
The ``can_manage_reusable_media`` permission has been added.
Team member resource
--------------------

View File

@@ -1,232 +0,0 @@
.. _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.

View File

@@ -14,7 +14,6 @@ The voucher resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the voucher
created datetime The creation date of the voucher. For vouchers created before pretix 2025.7.0, this is guessed retroactively and might not be accurate.
code string The voucher code that is required to redeem the voucher
max_usages integer The maximum number of times this voucher can be
redeemed (default: 1).
@@ -50,8 +49,6 @@ 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.
===================================== ========================== =======================================================
@@ -85,7 +82,6 @@ Endpoints
"results": [
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
@@ -103,9 +99,7 @@ Endpoints
"subevent": null,
"show_hidden_items": false,
"all_addons_included": false,
"all_bundles_included": false,
"budget": None,
"budget_used": "0.00"
"all_bundles_included": false
}
]
}
@@ -158,7 +152,6 @@ Endpoints
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
@@ -176,9 +169,7 @@ Endpoints
"subevent": null,
"show_hidden_items": false,
"all_addons_included": false,
"all_bundles_included": false,
"budget": None,
"budget_used": "0.00"
"all_bundles_included": false
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -231,7 +222,6 @@ Endpoints
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
@@ -249,9 +239,7 @@ Endpoints
"subevent": null,
"show_hidden_items": false,
"all_addons_included": false,
"all_bundles_included": false,
"budget": None,
"budget_used": "0.00"
"all_bundles_included": false
}
:param organizer: The ``slug`` field of the organizer to create a voucher for
@@ -325,7 +313,6 @@ Endpoints
[
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
}, …
@@ -372,7 +359,6 @@ Endpoints
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
@@ -390,9 +376,7 @@ Endpoints
"subevent": null,
"show_hidden_items": false,
"all_addons_included": false,
"all_bundles_included": false,
"budget": None,
"budget_used": "0.00"
"all_bundles_included": false
}
:param organizer: The ``slug`` field of the organizer to modify

View File

@@ -178,6 +178,13 @@ You can then implement a view as you would normally do. It will be automatically
* Your plugin is enabled
* The locale is set correctly
.. versionchanged:: 1.7
The ``event_url()`` wrapper has been added in 1.7 to replace the former ``@event_view`` decorator. The
``event_url()`` wrapper is optional and using ``url()`` still works, but you will not be able to set the
``require_live`` setting any more via the decorator. The ``@event_view`` decorator is now deprecated and
does nothing.
REST API viewsets
-----------------

View File

@@ -1,200 +0,0 @@
.. 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

View File

@@ -30,7 +30,7 @@ Check-ins
.. automodule:: pretix.base.signals
:no-index:
:members: checkin_created, checkin_annulled
:members: checkin_created
Frontend

View File

@@ -10,6 +10,7 @@ Contents:
exporter
ticketoutput
payment
payment_2.0
email
placeholder
invoice
@@ -18,6 +19,5 @@ Contents:
customview
cookieconsent
auth
datasync
general
quality

View File

@@ -0,0 +1,129 @@
.. highlight:: python
:linenothreshold: 5
.. _`payment2.0`:
Porting a payment provider from pretix 1.x to pretix 2.x
========================================================
In pretix 2.x, we changed large parts of the payment provider API. This documentation details the changes we made
and shows you how you can make an existing pretix 1.x payment provider compatible with pretix 2.x
Conceptual overview
-------------------
In pretix 1.x, an order was always directly connected to a payment provider for the full life of an order. As long as
an order was unpaid, this could still be changed in some cases, but once an order was paid, no changes to the payment
provider were possible any more. Additionally, the internal state of orders allowed orders only to be fully paid or
not paid at all. This leads to a couple of consequences:
* Payment-related functions (like "execute payment" or "do a refund") always operated on full orders.
* Changing the total of an order was basically impossible once an order was paid, since there was no concept of
partial payments or partial refunds.
* Payment provider plugins needed to take complicated steps to detect cases that require human intervention, like e.g.
* An order has expired, no quota is left to revive it, but a payment has been received
* A payment has been received for a canceled order
* A payment has been received for an order that has already been paid with a different payment method
* An external payment service notified us of a refund/dispute
We noticed that we copied and repeated large portions of code in all our official payment provider plugins, just
to deal with some of these cases.
* Sometimes, there is the need to mark an order as refunded within pretix, without automatically triggering a refund
with an external API. Every payment method needed to implement a user interface for this independently.
* If a refund was not possible automatically, there was no way user to track which payments actually have been refunded
manually and which are still left to do.
* When the payment with one payment provider failed and the user changed to a different payment provider, all
information about the first payment was lost from the order object and could only be retrieved from order log data,
which also made it hard to design a data shredder API to get rid of this data.
In pretix 2.x, we introduced two new models, :py:class:`OrderPayment <pretix.base.models.OrderPayment>` and
:py:class:`OrderRefund <pretix.base.models.OrderRefund>`. Each instance of these is connected to an order and
represents one single attempt to pay or refund a specific amount of money. Each one of these has an individual state,
can individually fail or succeed, and carries an amount variable that can differ from the order total.
This has the following advantages:
* The system can now detect orders that are over- or underpaid, independent of the payment providers in use.
* Therefore, we can now allow partial payments, partial refunds, and changing paid orders, and automatically detect
the cases listed above and notify the user.
Payment providers now interact with those payment and refund objects more than with orders.
Your to-do list
---------------
Payment processing
""""""""""""""""""
* The method ``BasePaymentProvider.order_pending_render`` has been removed and replaced by a new
``BasePaymentProvider.payment_pending_render(request, payment)`` method that is passed an ``OrderPayment``
object instead of an ``Order``.
* The method ``BasePaymentProvider.payment_form_render`` now receives a new ``total`` parameter.
* The method ``BasePaymentProvider.payment_perform`` has been removed and replaced by a new method
``BasePaymentProvider.execute_payment(request, payment)`` that is passed an ``OrderPayment``
object instead of an ``Order``.
* The function ``pretix.base.services.mark_order_paid`` has been removed, instead call ``payment.confirm()``
on a pending ``OrderPayment`` object. If no further payments are required for this order, this will also
mark the order as paid automatically. Note that ``payment.confirm()`` can still throw a ``QuotaExceededException``,
however it will still mark the payment as complete (not the order!), so you should catch this exception and
inform the user, but not abort the transaction.
* A new property ``BasePaymentProvider.abort_pending_allowed`` has been introduced. Only if set, the user will
be able to retry a payment or switch the payment method when the order currently has a payment object in
state ``"pending"``. This replaces ``BasePaymentProvider.order_can_retry``, which no longer exists.
* The methods ``BasePaymentProvider.retry_prepare`` and ``BasePaymentProvider.order_prepare`` have both been
replaced by a new method ``BasePaymentProvider.payment_prepare(request, payment)`` that is passed an ``OrderPayment``
object instead of an ``Order``. **Keep in mind that this payment object might have an amount property that
differs from the order total, if the order is already partially paid.**
* The method ``BasePaymentProvider.order_paid_render`` has been removed.
* The method ``BasePaymentProvider.order_control_render`` has been removed and replaced by a new method
``BasePaymentProvider.payment_control_render(request, payment)`` that is passed an ``OrderPayment``
object instead of an ``Order``.
* There's no need to manually deal with excess payments or duplicate payments anymore, just setting the ``OrderPayment``
methods to the correct state will do the job.
Creating refunds
""""""""""""""""
* The methods ``BasePaymentProvider.order_control_refund_render`` and ``BasePaymentProvider.order_control_refund_perform``
have been removed.
* Two new boolean methods ``BasePaymentProvider.payment_refund_supported(payment)`` and ``BasePaymentProvider.payment_partial_refund_supported(payment)``
have been introduced. They should be set to return ``True`` if and only if the payment API allows to *automatically*
transfer the money back to the customer.
* A new method ``BasePaymentProvider.execute_refund(refund)`` has been introduced. This method is called using a
``OrderRefund`` object in ``"created"`` state and is expected to transfer the money back and confirm success with
calling ``refund.done()``. This will only ever be called if either ``BasePaymentProvider.payment_refund_supported(payment)``
or ``BasePaymentProvider.payment_partial_refund_supported(payment)`` return ``True``.
Processing external refunds
"""""""""""""""""""""""""""
* If e.g. a webhook API notifies you that a payment has been disputed or refunded with the external API, you are
expected to call ``OrderPayment.create_external_refund(self, amount, execution_date, info='{}')`` on this payment.
This will create and return an appropriate ``OrderRefund`` object and send out a notification. However, it will not
mark the order as refunded, but will ask the event organizer for a decision.
Data shredders
""""""""""""""
* The method ``BasePaymentProvider.shred_payment_info`` is no longer passed an order, but instead **either**
an ``OrderPayment`` **or** an ``OrderRefund``.

View File

@@ -84,8 +84,6 @@ A working example would be:
restricted = False
description = _("This plugin allows you to receive payments via PayPal")
compatibility = "pretix>=2.7.0"
settings_links = []
navigation_links = []
default_app_config = 'pretix_paypal.PaypalApp'
@@ -187,28 +185,6 @@ your Django app label.
with checking that the calling user is logged in, has appropriate permissions,
etc. We plan on providing native support for this in a later version.
To make your plugin views easily discoverable, you can specify links for "Go to"
and "Settings" buttons next to your entry on the plugin page. These links should be
added to the ``navigation_links`` and ``settings_links``, respectively, in the
``PretixPluginMeta`` class.
Each array entry consists of a tuple ``(label, urlname, kwargs)``. For the label,
either a string or a tuple of strings can be specified. In the latter case, the provided
strings will be merged with a separator indicating they are successive navigation steps
the user would need to take to reach the page via the regular menu
(e.g. "Payment > Bank transfer" as below).
.. code-block:: python
settings_links = [
((_("Payment"), _("Bank transfer")), "control:event.settings.payment.provider", {"provider": "banktransfer"}),
]
navigation_links = [
((_("Bank transfer"), _("Import bank data")), "plugins:banktransfer:import", {}),
((_("Bank transfer"), _("Export refunds")), "plugins:banktransfer:refunds.list", {}),
]
.. _Django app: https://docs.djangoproject.com/en/3.0/ref/applications/
.. _signal dispatcher: https://docs.djangoproject.com/en/3.0/topics/signals/
.. _namespace packages: https://legacy.python.org/dev/peps/pep-0420/

View File

@@ -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`_ instead.
If you want to install pretix on a server for actual usage, go to the [administrator documentation](https://docs.pretix.eu/self-hosting/) instead.
Obtain a copy of the source code
--------------------------------
@@ -221,4 +221,3 @@ 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/

177
doc/license/faq.rst Normal file
View File

@@ -0,0 +1,177 @@
.. spelling:word-list::
AGPL
AGPLv3
GPL
LGPL
Apache
BSD
MIT
CLA
django
i18nfields
hierarkey
rami.io
rami
io
GmbH
License FAQ
===========
.. warning::
This FAQ tries to explain in simpler terms what the license of the pretix open source project does and does not
allow. It is based on our interpretation of the license and is not legal advice. The contents of this page are not
legally binding, only the original text of the license in the `license file`_ is legally binding.
How is pretix licensed?
-----------------------
pretix follows the popular dual licensing model. It is available under the `GNU Affero General Public License 3`_ (AGPL)
plus some additional terms, as well as under a proprietary license ("pretix Enterprise license") on request.
How can it be AGPL if there are additional terms?
-------------------------------------------------
Even though it is fairly unknown, the AGPL's section 7 is titled "Additional Terms" and outlines specific conditions
under which additional terms can be imposed on an AGPL-licensed work. In our case, we add three additional terms.
The first additional term for pretix is an additional **permission**. It allows you to do something that the AGPL would
generally not allow. As it doesn't restrict your freedoms granted by AGPL, if you don't like it, you can ignore it, and
if you distribute pretix further, you can remove it.
The second and third additional term for pretix are additional terms that restrict or specify other provisions of the
license. AGPL specifically requires that these terms can only restrict or specify very specific things and we believe
our additional terms are in compliance with that and are thus valid and may not be removed.
Why did you choose this license model?
--------------------------------------
pretix was born in the open source community and we're deeply committed to building the best open source ticketing
solution in the world. It is important to us that pretix is available with a comprehensive feature set under term that
are compatible with the `Open Source Definition`_. This enables event organizers from all industries and regions
to have access to a self-hosted, privacy-friendly and secure option to host their events.
However, developing and maintaining pretix is a lot of work. Between 2014 and 2021, we've received external
contributions from more than 150 individuals. Not counting translations over 90 % of the development was
done by staff engineers of rami.io GmbH, the company that started pretix. While we're very happy to receive many more
contributions in the future, we also want to ensure that we continue to be able to pay people working on pretix
full-time.
We believe our model creates a good balance between ensuring pretix is available freely as well as protecting our
business interests. Unlike licenses chosen by other projects recently, such as the Server-Side Public License, our
choice does not restrict using pretix for any possible use case, it just sets a few rules that you have to play by
if you do.
What do I need to do if I use pretix unmodified?
------------------------------------------------
If you use pretix without any modifications or plugins, you can use it for whatever you want, as long as you keep
all copyright notices (including the link to pretix at the bottom of the site) intact.
You are also allowed to make copies of the unmodified source code and distribute them to others as long as you keep
all copyright and license information intact.
If you install **plugins**, you must follow the same terms as when using a **modified** version (see below).
What do I need to do if I modify pretix?
----------------------------------------
If you want to modify pretix, you have the right to do so. However, you need to follow the following rules:
* If you **run it for your own events** (events run by you or your company as well as companies from the same
corporate groups) our additional permission allows you to do so **without needing to share your source code
modifications** as long as you keep the link to pretix at the bottom of the site intact.
* If you **run it for others**, for example as part of a Software-as-a-Service offering or a managed hosting service
you **must** make the source code **including all your modifications and all installed plugins** available under the
same license as pretix to every visitor of your site. You need to do so in a prominent place such as a link at the bottom of the
site. You also **must** keep the existing link intact.
You **may not** add additional restrictions on the result as a whole. You **may** add additional permissions, but
only on the parts you added. You **must** make clear which changes you made and you must not give the impression that
your modified version is an official version of pretix.
* If you **distribute** the modified version, for example as a source code or software package, you **must** license it
under the AGPL license with the same additional terms. You **may not** add additional restrictions on the result as a
whole. You **may** add additional permissions, but only on the parts you added. You **must** make clear which changes
you made and you must not give the impression that your modified version is an official version of pretix.
Does the AGPL copyleft mechanism extend to plugins?
---------------------------------------------------
Yes. pretix plugins are tightly integrated with pretix, so when running pretix together with a plugin in the same
environment they form a `combined work`_ and the copyleft mechanism of AGPL applies.
Can I create proprietary or secret plugins?
-------------------------------------------
Yes, you can create a proprietary or secret plugin, but it may only ever be **used** in an environment that is covered
by the additional permission from our license. As soon as the plugin is installed in an installation that is not covered
by our additional permission (e.g. when it is used in a SaaS environment) or covered by an active pretix Enterprise
license it **must** be released to the visitors of the site under the same license as pretix (like a modified version
of pretix).
What licenses can plugins use?
------------------------------
Technically, you can distribute a plugin under any free or proprietary license as long as it is distributed separately.
However, once it is either **distributed together with pretix or used in an environment not covered by our
additional permission** or an active pretix Enterprise license, you **must** release it to all recipients of the
distribution or all visitors of your site under the same license as pretix (like a modified version of pretix).
If you release a plugin publicly, it is therefore most practical to use a license that is `compatible to AGPL`_.
This includes most open source licenses such as AGPL, GPL, Apache, 3-clause BSD or MIT.
Note however that when you license a plugin with pure AGPL, it will be incompatible with our additional permission.
Therefore, if you want to use an AGPL-licensed plugin, you'll need to publish the source code of **all** your plugins
under AGPL terms **even if you only use it for your own events**. A plugin would add its `own additional permission`_
to its license to allow combining it with pretix for this use case.
To make things less complicated, if you want to distribute a plugin freely, we therefore recommend distributing the
plugin under **Apache License 2.0**, like we do for most plugins we distribute as open source.
What do I need to do if I want to contribute my changes back?
-------------------------------------------------------------
In order to retain the possibility for us to offer pretix in a dual licensing model, we unfortunately need you to sign
a Contributor License Agreement (CLA) that gives us permission to use your contribution in all present and future
distributions of pretix. We know the bureaucracy sucks. Sorry.
What if I want to re-use a minor part of pretix in my project?
--------------------------------------------------------------
This is the main part we dislike about AGPL: If you see a specific thing in pretix that you'd like to use in another
project, you'll need to distribute your other project under AGPL terms as well which is often not practical.
In this case, feel free to get in touch with us! We're happy to grant you special permission or pull the component
out into a separately, permissively licensed repository. We already did that with `django-hierarkey`_ and
`django-i18nfield`_ which have previously been parts of pretix.
What can I use the name "pretix" for?
-------------------------------------
The name pretix is a registered trademark by rami.io GmbH.
* You **may** use it to **indicate copyright**, such as in the "powered by pretix" or "based on pretix" line, or when
indicating that a distribution is based on pretix.
* You **may** use it to **indicate compatibility**, for example you are allowed to name your plugin "<name> for pretix"
or you may state that an external service is compatible with pretix.
* You **may not** give the impression that your modified version, plugin or compatible service is official or authorized
by rami.io GmbH or pretix unless we specifically allowed you to do so.
* You **may not** use it to name your modified version of pretix. End-users must be able to easily identify whether
a version of pretix is distributed by us.
* You **may not** use any variations of the name, such as "MyPretix".
.. _license file: https://github.com/pretix/pretix/blob/master/LICENSE
.. _GNU Affero General Public License 3: https://www.gnu.org/licenses/agpl-3.0.en.html
.. _compatible to AGPL: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses
.. _Open Source Definition: https://opensource.org/osd
.. _combined work: https://www.gnu.org/licenses/gpl-faq.html#GPLPlugins
.. _own additional permission: https://www.gnu.org/licenses/gpl-faq.html#GPLIncompatibleLibs
.. _django-hierarkey: https://github.com/raphaelm/django-hierarkey
.. _django-i18nfield: https://github.com/raphaelm/django-i18nfield

View File

@@ -30,68 +30,68 @@ dependencies = [
"babel",
"BeautifulSoup4==4.13.*",
"bleach==6.2.*",
"celery==5.5.*",
"celery==5.4.*",
"chardet==5.2.*",
"cryptography>=44.0.0",
"css-inline==0.17.*",
"css-inline==0.14.*",
"defusedcsv>=1.1.0",
"Django[argon2]==4.2.*,>=4.2.15",
"django-bootstrap3==25.2",
"django-bootstrap3==25.1",
"django-compressor==4.5.1",
"django-countries==7.6.*",
"django-filter==25.1",
"django-formset-js-improved==0.5.0.3",
"django-formtools==2.5.1",
"django-hierarkey==2.0.*",
"django-hierarkey==1.2.*",
"django-hijack==3.7.*",
"django-i18nfield==1.10.*",
"django-libsass==0.9",
"django-localflavor==5.0",
"django-localflavor==4.0",
"django-markup",
"django-oauth-toolkit==2.3.*",
"django-otp==1.6.*",
"django-otp==1.5.*",
"django-phonenumber-field==7.3.*",
"django-redis==6.0.*",
"django-redis==5.4.*",
"django-scopes==2.0.*",
"django-statici18n==2.6.*",
"djangorestframework==3.16.*",
"djangorestframework==3.15.*",
"dnspython==2.7.*",
"drf_ujson2==1.7.*",
"geoip2==5.*",
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
"isoweek",
"jsonschema",
"kombu==5.5.*",
"kombu==5.4.*",
"libsass==0.23.*",
"lxml",
"markdown==3.8.2", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
"markdown==3.7", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*",
"oauthlib==3.3.*",
"oauthlib==3.2.*",
"openpyxl==3.1.*",
"packaging",
"paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.10.*",
"phonenumberslite==9.0.*",
"Pillow==11.3.*",
"phonenumberslite==8.13.*",
"Pillow==11.1.*",
"pretix-plugin-build",
"protobuf==6.31.*",
"protobuf==5.29.*",
"psycopg2-binary",
"pycountry",
"pycparser==2.22",
"pycryptodome==3.23.*",
"pypdf==6.0.*",
"pycryptodome==3.21.*",
"pypdf==5.1.*",
"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.4.*",
"reportlab==4.4.*",
"qrcode==8.0",
"redis==5.2.*",
"reportlab==4.3.*",
"requests==2.31.*",
"sentry-sdk==2.34.*",
"sentry-sdk==2.22.*",
"sepaxml==2.6.*",
"stripe==7.9.*",
"text-unidecode==1.*",
@@ -100,21 +100,21 @@ dependencies = [
"ua-parser==1.0.*",
"vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*",
"webauthn==2.6.*",
"webauthn==2.5.*",
"zeep==4.3.*"
]
[project.optional-dependencies]
memcached = ["pylibmc"]
dev = [
"aiohttp==3.12.*",
"aiohttp==3.11.*",
"coverage",
"coveralls",
"fakeredis==2.31.*",
"flake8==7.3.*",
"fakeredis==2.26.*",
"flake8==7.1.*",
"freezegun",
"isort==6.0.*",
"pep8-naming==0.15.*",
"pep8-naming==0.14.*",
"potypo",
"pytest-asyncio>=0.24",
"pytest-cache",
@@ -122,8 +122,8 @@ dev = [
"pytest-django==4.*",
"pytest-mock==3.14.*",
"pytest-sugar",
"pytest-xdist==3.8.*",
"pytest==8.4.*",
"pytest-xdist==3.6.*",
"pytest==8.3.*",
"responses",
]

View File

@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2025.7.0.dev0"
__version__ = "2025.3.0.dev0"

View File

@@ -101,7 +101,6 @@ ALL_LANGUAGES = [
('fi', _('Finnish')),
('gl', _('Galician')),
('el', _('Greek')),
('he', _('Hebrew')),
('id', _('Indonesian')),
('it', _('Italian')),
('ja', _('Japanese')),
@@ -115,7 +114,6 @@ ALL_LANGUAGES = [
('sk', _('Slovak')),
('sv', _('Swedish')),
('es', _('Spanish')),
('es-419', _('Spanish (Latin America)')),
('tr', _('Turkish')),
('uk', _('Ukrainian')),
]
@@ -123,8 +121,7 @@ LANGUAGES_OFFICIAL = {
'en', 'de', 'de-informal'
}
LANGUAGES_RTL = {
# When adding more right-to-left languages, also update pretix/static/pretixbase/scss/_rtl.scss
'ar', 'he'
'ar', 'hw'
}
LANGUAGES_INCUBATING = {
'pt-br', 'gl',
@@ -173,12 +170,6 @@ 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)

View File

@@ -1,18 +0,0 @@
# Generated by Django 4.2.17 on 2025-06-24 14:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixapi", "0012_oauthapplication_post_logout_redirect_uris"),
]
operations = [
migrations.AlterField(
model_name="webhookcallretry",
name="retry_not_before",
field=models.DateTimeField(),
),
]

View File

@@ -157,7 +157,7 @@ class WebHookCallRetry(models.Model):
id = models.BigAutoField(primary_key=True)
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='retries')
logentry = models.ForeignKey('pretixbase.LogEntry', on_delete=models.CASCADE, related_name='webhook_retries')
retry_not_before = models.DateTimeField()
retry_not_before = models.DateTimeField(auto_now_add=True)
retry_count = models.PositiveIntegerField(default=0)
action_type = models.CharField(max_length=255)

View File

@@ -81,13 +81,6 @@ class SalesChannelMigrationMixin:
def to_internal_value(self, data):
if "sales_channels" in data:
if data["sales_channels"] is None:
raise ValidationError({
"sales_channels": [
"The legacy attribute 'sales_channels' cannot be set to None, it must be a list."
]
})
prefetch_related_objects([self.organizer], "sales_channels")
all_channels = {
s.identifier for s in
@@ -96,7 +89,7 @@ class SalesChannelMigrationMixin:
if data.get("all_sales_channels") and set(data["sales_channels"]) != all_channels:
raise ValidationError({
"all_sales_channels": [
"limit_sales_channels": [
"If 'all_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to "
"the list of all sales channels."
]
@@ -116,7 +109,6 @@ class SalesChannelMigrationMixin:
else:
data["all_sales_channels"] = False
data["limit_sales_channels"] = data["sales_channels"]
del data["sales_channels"]
if data.get("all_sales_channels"):

View File

@@ -176,7 +176,7 @@ class BaseCartPositionCreateSerializer(I18nAwareModelSerializer):
def create(self, validated_data):
validated_data.pop('_quotas')
answers_data = validated_data.pop('answers', [])
answers_data = validated_data.pop('answers')
attendee_name = validated_data.pop('attendee_name', '')
if attendee_name and not validated_data.get('attendee_name_parts'):

View File

@@ -84,7 +84,6 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=Checkin.CHECKIN_TYPES, default=Checkin.TYPE_ENTRY)
ignore_unpaid = serializers.BooleanField(default=False, required=False)
questions_supported = serializers.BooleanField(default=True, required=False)
use_order_locale = serializers.BooleanField(default=False, required=False)
nonce = serializers.CharField(required=False, allow_null=True)
datetime = serializers.DateTimeField(required=False, allow_null=True)
answers = serializers.JSONField(required=False, allow_null=True)
@@ -104,14 +103,3 @@ class MiniCheckinListSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class CheckinRPCAnnulInputSerializer(serializers.Serializer):
lists = serializers.PrimaryKeyRelatedField(required=True, many=True, queryset=CheckinList.objects.none())
nonce = serializers.CharField(required=True, allow_null=False)
datetime = serializers.DateTimeField(required=False, allow_null=True)
error_explanation = serializers.CharField(required=False, allow_null=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event')

View File

@@ -38,12 +38,11 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
class Meta:
model = Discount
fields = ('id', 'active', 'internal_name', 'position', 'all_sales_channels', 'limit_sales_channels',
'available_from', 'available_until', 'subevent_mode', 'subevent_date_from', 'subevent_date_until',
'condition_all_products', 'condition_limit_products', 'condition_apply_to_addons',
'condition_min_count', 'condition_min_value', 'benefit_discount_matching_percent',
'benefit_only_apply_to_cheapest_n_matches', 'benefit_same_products', 'benefit_limit_products',
'benefit_apply_to_addons', 'benefit_ignore_voucher_discounted',
'condition_ignore_voucher_discounted')
'available_from', 'available_until', 'subevent_mode', 'condition_all_products',
'condition_limit_products', 'condition_apply_to_addons', 'condition_min_count', 'condition_min_value',
'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches',
'benefit_same_products', 'benefit_limit_products', 'benefit_apply_to_addons',
'benefit_ignore_voucher_discounted', 'condition_ignore_voucher_discounted')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -378,8 +378,6 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
if prop.name not in meta_data:
current_object.delete()
instance._prefetched_objects_cache.clear()
# Item Meta properties
if item_meta_properties is not None:
current = list(event.item_meta_properties.all())
@@ -400,8 +398,6 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
if prop.name not in list(item_meta_properties.keys()):
prop.delete()
instance._prefetched_objects_cache.clear()
# Seats
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
current_mappings = {
@@ -685,26 +681,8 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
class Meta:
model = TaxRule
fields = ('id', 'name', 'default', 'rate', 'code', 'price_includes_tax', 'eu_reverse_charge', 'home_country',
'internal_name', 'keep_gross_if_rate_changes', 'custom_rules', 'default')
def create(self, validated_data):
if "default" not in validated_data and not self.context["event"].tax_rules.exists():
validated_data["default"] = True
return super().create(validated_data)
def save(self, **kwargs):
if self.validated_data.get("default"):
if self.instance and self.instance.pk:
self.context["event"].tax_rules.exclude(pk=self.instance.pk).update(default=False)
else:
self.context["event"].tax_rules.update(default=False)
return super().save(**kwargs)
def validate_default(self, value):
if not value and self.instance.default:
raise ValidationError("You can't remove the default property, instead set it on another tax rule.")
return value
fields = ('id', 'name', 'rate', 'code', 'price_includes_tax', 'eu_reverse_charge', 'home_country',
'internal_name', 'keep_gross_if_rate_changes', 'custom_rules')
class EventSettingsSerializer(SettingsSerializer):
@@ -730,8 +708,6 @@ class EventSettingsSerializer(SettingsSerializer):
'allow_modifications_after_checkin',
'last_order_modification_date',
'show_quota_left',
'tax_rule_payment',
'tax_rule_cancellation',
'waiting_list_enabled',
'waiting_list_auto_disable',
'waiting_list_hours',
@@ -962,8 +938,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'reusable_media_type_nfc_mf0aes',
'reusable_media_type_nfc_mf0aes_random_uid',
'system_question_order',
'tax_rule_payment',
'tax_rule_cancellation',
]
def __init__(self, *args, **kwargs):

View File

@@ -505,11 +505,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
Question._clean_identifier(self.context['event'], value, self.instance)
return value
def validate_type(self, value):
if self.instance:
self.instance.clean_type_change(self.instance.type, value)
return value
def validate_dependency_question(self, value):
if value:
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
@@ -582,7 +577,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', 'ignore_for_event_availability')
'release_after_exit', 'available', 'available_number')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -56,7 +56,7 @@ from pretix.base.models import (
)
from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
PrintLog, RevokedTicketSecret, Transaction,
PrintLog, RevokedTicketSecret,
)
from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages
@@ -607,7 +607,6 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order')
order__valid_if_pending = serializers.SlugRelatedField(read_only=True, slug_field='valid_if_pending', source='order')
order__require_approval = serializers.SlugRelatedField(read_only=True, slug_field='require_approval', source='order')
order__locale = serializers.SlugRelatedField(read_only=True, slug_field='locale', source='order')
class Meta:
model = OrderPosition
@@ -616,7 +615,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat',
'require_attention', 'order__status', 'order__valid_if_pending', 'order__require_approval',
'order__locale', 'valid_from', 'valid_until', 'blocked')
'valid_from', 'valid_until', 'blocked')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -1519,8 +1518,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
self.context['event'],
order.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
(cp.item_id, cp.subevent_id, cp.price, bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
for cp in order_positions
]
)
@@ -1600,7 +1598,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
self.context['event'].currency)
is_split_taxes = fee_data.pop('_split_taxes_like_products', False)
if is_split_taxes and order.total:
if is_split_taxes:
d = defaultdict(lambda: Decimal('0.00'))
trz = TaxRule.zero()
for p in pos_map.values():
@@ -1783,23 +1781,3 @@ 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",)

View File

@@ -83,7 +83,6 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
def create(self, validated_data):
ocm = self.context['ocm']
check_quotas = self.context.get('check_quotas', True)
try:
ocm.add_position(
@@ -97,7 +96,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
valid_until=validated_data.get('valid_until'),
)
if self.context.get('commit', True):
ocm.commit(check_quotas=check_quotas)
ocm.commit()
return validated_data['order'].positions.order_by('-positionid').first()
else:
return OrderPosition() # fake to appease DRF
@@ -252,7 +251,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
class Meta:
model = OrderPosition
fields = (
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule', 'valid_from', 'valid_until', 'secret'
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule', 'valid_from', 'valid_until'
)
def __init__(self, *args, **kwargs):
@@ -311,7 +310,6 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
ocm = self.context['ocm']
check_quotas = self.context.get('check_quotas', True)
current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None
item = validated_data.get('item', instance.item)
variation = validated_data.get('variation', instance.variation)
@@ -321,7 +319,6 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
tax_rule = validated_data.get('tax_rule', instance.tax_rule)
valid_from = validated_data.get('valid_from', instance.valid_from)
valid_until = validated_data.get('valid_until', instance.valid_until)
secret = validated_data.get('secret', instance.secret)
change_item = None
if item != instance.item or variation != instance.variation:
@@ -354,11 +351,8 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
if valid_until != instance.valid_until:
ocm.change_valid_until(instance, valid_until)
if secret != instance.secret:
ocm.change_ticket_secret(instance, secret)
if self.context.get('commit', True):
ocm.commit(check_quotas=check_quotas)
ocm.commit()
instance.refresh_from_db()
except OrderError as e:
raise ValidationError(str(e))

View File

@@ -426,9 +426,6 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'organizer_logo_image_inherit',
'organizer_logo_image',
'privacy_url',
'accessibility_url',
'accessibility_title',
'accessibility_text',
'cookie_consent',
'cookie_consent_dialog_title',
'cookie_consent_dialog_text',

View File

@@ -19,8 +19,6 @@
# 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
@@ -66,15 +64,14 @@ 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', 'created', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota',
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', 'budget', 'budget_used')
read_only_fields = ('id', 'redeemed', 'budget_used')
'all_bundles_included')
read_only_fields = ('id', 'redeemed')
list_serializer_class = VoucherListSerializer
def validate(self, data):

View File

@@ -66,7 +66,6 @@ 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)
@@ -84,7 +83,6 @@ 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')
@@ -132,8 +130,6 @@ urlpatterns = [
name="checkinrpc.redeem"),
re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/search/$', checkin.CheckinRPCSearchView.as_view(),
name="checkinrpc.search"),
re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/annul/$', checkin.CheckinRPCAnnulView.as_view(),
name="checkinrpc.annul"),
re_path(r'^organizers/(?P<organizer>[^/]+)/settings/$', organizer.OrganizerSettingsView.as_view(),
name="organizer.settings"),
re_path(r'^organizers/(?P<organizer>[^/]+)/giftcards/(?P<giftcard>[^/]+)/', include(giftcard_router.urls)),

View File

@@ -20,13 +20,12 @@
# <https://www.gnu.org/licenses/>.
#
import operator
from datetime import timedelta
from functools import reduce
import django_filters
from django.conf import settings
from django.core.exceptions import ValidationError as BaseValidationError
from django.db import connection, transaction
from django.db import transaction
from django.db.models import (
Count, Exists, F, Max, OrderBy, OuterRef, Prefetch, Q, Subquery,
prefetch_related_objects,
@@ -40,19 +39,17 @@ from django.utils.translation import gettext
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from packaging.version import parse
from rest_framework import status, views, viewsets
from rest_framework import views, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import (
NotFound, PermissionDenied, ValidationError,
)
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import DateTimeField
from rest_framework.generics import ListAPIView
from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response
from pretix.api.serializers.checkin import (
CheckinListSerializer, CheckinRPCAnnulInputSerializer,
CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
CheckinListSerializer, CheckinRPCRedeemInputSerializer,
MiniCheckinListSerializer,
)
from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import (
@@ -69,8 +66,6 @@ from pretix.base.models.orders import PrintLog
from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
)
from pretix.base.signals import checkin_annulled
from pretix.helpers import OF_SELF
with scopes_disabled():
class CheckinListFilter(FilterSet):
@@ -170,7 +165,6 @@ class CheckinListViewSet(viewsets.ModelViewSet):
if not serializer.validated_data.get('position'):
kwargs['position'] = OrderPosition.all.filter(
order__event=self.request.event,
secret=serializer.validated_data['raw_barcode']
).first()
@@ -425,7 +419,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce,
untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported,
source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False):
source_type='barcode', legacy_url_support=False, simulate=False, gate=None):
if not checkinlists:
raise ValidationError('No check-in list passed.')
@@ -699,11 +693,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
pass
# 6. Pass to our actual check-in logic
if use_order_locale:
locale = op.order.locale
else:
locale = op.order.event.settings.locale
with language(locale):
with language(op.order.event.settings.locale):
try:
perform_checkin(
op=op,
@@ -918,7 +908,6 @@ class CheckinRPCRedeemView(views.APIView):
expand=self.request.query_params.getlist('expand'),
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
questions_supported=s.validated_data['questions_supported'],
use_order_locale=s.validated_data['use_order_locale'],
canceled_supported=True,
request=self.request, # this is not clean, but we need it in the serializers for URL generation
legacy_url_support=False,
@@ -1004,79 +993,3 @@ class CheckinRPCSearchView(ListAPIView):
qs = qs.none()
return qs
class CheckinRPCAnnulView(views.APIView):
def post(self, request, *args, **kwargs):
if isinstance(self.request.auth, (TeamAPIToken, Device)):
events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders'))
elif self.request.user.is_authenticated:
events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter(
organizer=self.request.organizer
)
else:
raise ValueError("unknown authentication method")
s = CheckinRPCAnnulInputSerializer(data=request.data, context={'events': events})
s.is_valid(raise_exception=True)
with transaction.atomic():
try:
qs = Checkin.all.all()
if isinstance(request.auth, Device):
qs = qs.filter(device=request.auth)
ci = qs.select_for_update(
of=OF_SELF,
).select_related("position", "position__order", "position__order__event").get(
list__in=s.validated_data['lists'],
nonce=s.validated_data['nonce'],
)
if connection.features.has_select_for_update_of and ci.position_id:
# Lock position as well, can't do it with of= above because relation is nullable
OrderPosition.objects.select_for_update(of=OF_SELF).get(pk=ci.position_id)
if not ci.successful or not ci.position:
raise ValidationError("Cannot annul an unsuccessful checkin")
except Checkin.DoesNotExist:
raise NotFound("No check-in found based on nonce")
except Checkin.MultipleObjectsReturned:
raise ValidationError("Multiple check-ins found based on nonce")
annulment_time = s.validated_data.get("datetime") or now()
if annulment_time - ci.datetime > timedelta(minutes=15):
# Compare to sent datetime, which makes this cheatable, but allows offline annulment of checkins
ci.position.order.log_action('pretix.event.checkin.annulment.ignored', data={
'checkin': ci.pk,
'position': ci.position.id,
'positionid': ci.position.positionid,
'datetime': annulment_time,
'error_explanation': s.validated_data.get("error_explanation"),
'type': ci.type,
'list': ci.list_id,
}, user=request.user, auth=request.auth)
return Response({
"non_field_errors": ["Annulment is not allowed more than 15 minutes after check-in"]
}, status=status.HTTP_400_BAD_REQUEST)
if ci.device and ci.device != request.auth:
return Response({
"non_field_errors": ["Annulment is only allowed from the same device"]
}, status=status.HTTP_400_BAD_REQUEST)
ci.successful = False
ci.error_reason = Checkin.REASON_ANNULLED
ci.error_explanation = s.validated_data.get("error_explanation")
ci.save(update_fields=["successful", "error_reason", "error_explanation"])
ci.position.order.log_action('pretix.event.checkin.annulled', data={
'checkin': ci.pk,
'position': ci.position.id,
'positionid': ci.position.positionid,
'datetime': annulment_time,
'error_explanation': s.validated_data.get("error_explanation"),
'type': ci.type,
'list': ci.list_id,
}, user=request.user, auth=request.auth)
checkin_annulled.send(ci.position.order.event, checkin=ci)
return Response({"status": "ok"}, status=status.HTTP_200_OK)

View File

@@ -580,11 +580,6 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
)
super().perform_destroy(instance)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx["event"] = self.request.event
return ctx
class ItemMetaPropertiesViewSet(viewsets.ModelViewSet):
serializer_class = ItemMetaPropertiesSerializer

View File

@@ -485,17 +485,8 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance)
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
pass
with scopes_disabled():
class QuotaFilter(FilterSet):
items__in = NumberInFilter(
field_name='items__id',
lookup_expr='in',
)
class Meta:
model = Quota
fields = {
@@ -517,7 +508,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
return self.request.event.quotas.all()
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset()).distinct()
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)

View File

@@ -57,9 +57,9 @@ from pretix.api.serializers.order import (
BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer,
OrderPaymentCreateSerializer, OrderPaymentSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer,
OrderRefundSerializer, OrderSerializer, OrganizerTransactionSerializer,
PriceCalcSerializer, PrintLogSerializer, RevokedTicketSecretSerializer,
SimulatedOrderSerializer, TransactionSerializer,
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer,
PrintLogSerializer, RevokedTicketSecretSerializer,
SimulatedOrderSerializer,
)
from pretix.api.serializers.orderchange import (
BlockNameSerializer, OrderChangeOperationSerializer,
@@ -80,7 +80,6 @@ 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
@@ -186,7 +185,7 @@ with scopes_disabled():
| Q(full_invoice_no__iexact=u)
).values_list('order_id', flat=True)
matching_positions = OrderPosition.all.filter(
matching_positions = OrderPosition.objects.filter(
Q(order=OuterRef('pk')) & Q(
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
| Q(secret__istartswith=u)
@@ -453,9 +452,10 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
comment = request.data.get('comment', None)
cancellation_fee = request.data.get('cancellation_fee', None)
if cancellation_fee:
cancellation_fee = serializers.DecimalField(max_digits=13, decimal_places=2).to_internal_value(
cancellation_fee,
)
try:
cancellation_fee = float(Decimal(cancellation_fee))
except:
cancellation_fee = None
order = self.get_object()
if not order.cancel_allowed():
@@ -943,7 +943,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
@action(detail=True, methods=['POST'])
def change(self, request, **kwargs):
order = self.get_object()
check_quotas = self.request.query_params.get('check_quotas', 'true') == 'true'
serializer = OrderChangeOperationSerializer(
context={'order': order, **self.get_serializer_context()},
@@ -1009,7 +1008,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
elif serializer.validated_data.get('recalculate_taxes') == 'keep_gross':
ocm.recalculate_taxes(keep='gross')
ocm.commit(check_quotas=check_quotas)
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
@@ -1088,7 +1087,6 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
ctx['check_quotas'] = self.request.query_params.get('check_quotas', 'true').lower() == 'true'
return ctx
def get_queryset(self):
@@ -2033,61 +2031,3 @@ 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

View File

@@ -476,7 +476,7 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int, retr
300, # + 5 minutes
1200, # + 20 minutes
3600, # + 60 minutes
14400, # + 4 hours
1440, # + 4 hours
21600, # + 6 hours
43200, # + 12 hours
43200, # + 24 hours
@@ -527,10 +527,8 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int, retr
if retry_count >= len(retry_intervals):
return 'retry-given-up'
elif retry_intervals[retry_count] < retry_celery_cutoff:
send_webhook.apply_async(
args=(logentry_id, action_type, webhook_id, retry_count + 1),
countdown=retry_intervals[retry_count]
)
send_webhook.apply_async(args=(logentry_id, action_type, webhook_id, retry_count + 1),
countdown=retry_intervals[retry_count])
return 'retry-via-celery'
else:
webhook.retries.update_or_create(
@@ -557,10 +555,7 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int, retr
if retry_count >= len(retry_intervals):
return 'retry-given-up'
elif retry_intervals[retry_count] < retry_celery_cutoff:
send_webhook.apply_async(
args=(logentry_id, action_type, webhook_id, retry_count + 1),
countdown=retry_intervals[retry_count]
)
send_webhook.apply_async(args=(logentry_id, action_type, webhook_id, retry_count + 1))
return 'retry-via-celery'
else:
webhook.retries.update_or_create(

View File

@@ -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, datasync, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
from .services import auth, checkin, currencies, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
from .models import _transactions # NOQA
from django.conf import settings

View File

@@ -35,22 +35,19 @@ def get_powered_by(request, safelink=True):
d = gs.settings.license_check_input
if d.get('poweredby_name'):
if d.get('poweredby_url'):
msg = gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>').format(
name=d['poweredby_name'],
a_name_attr='href="{}" target="_blank" rel="noopener"'.format(
sl(d['poweredby_url']) if safelink else d['poweredby_url'],
),
a_attr='href="{}" target="_blank" rel="noopener"'.format(
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
)
n = '<a href="{}" target="_blank" rel="noopener">{}</a>'.format(
sl(d['poweredby_url']) if safelink else d['poweredby_url'],
d['poweredby_name']
)
else:
msg = gettext('<a {a_attr}>powered by {name} based on pretix</a>').format(
name=d['poweredby_name'],
a_attr='href="{}" target="_blank" rel="noopener"'.format(
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
)
n = d['poweredby_name']
msg = gettext('powered by {name} based on <a {a_attr}>pretix</a>').format(
name=n,
a_attr='href="{}" target="_blank" rel="noopener"'.format(
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
)
)
else:
msg = gettext('<a %(a_attr)s>ticketing powered by pretix</a>') % {
'a_attr': 'href="{}" target="_blank" rel="noopener"'.format(
@@ -74,7 +71,7 @@ def contextprocessor(request):
try:
ctx['poweredby'] = get_powered_by(request, safelink=True)
except Exception:
ctx['poweredby'] = '<a href="https://pretix.eu/" target="_blank" rel="noopener">powered by pretix</a>'
ctx['poweredby'] = 'powered by <a href="https://pretix.eu/" target="_blank" rel="noopener">pretix</a>'
if settings.DEBUG and 'runserver' not in sys.argv:
ctx['debug_warning'] = True
elif 'runserver' in sys.argv:

View File

@@ -199,7 +199,6 @@ 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,
@@ -215,10 +214,7 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
resp.raise_for_status()
data = resp.json()
except RequestException:
if resp:
logger.exception(f'Could not retrieve authorization token. Response: {resp.text}')
else:
logger.exception('Could not retrieve authorization token')
logger.exception('Could not retrieve authorization token')
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='could not reach login provider',
@@ -226,7 +222,6 @@ 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',
@@ -234,7 +229,6 @@ 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(
@@ -246,10 +240,7 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
resp.raise_for_status()
userinfo = resp.json()
except RequestException:
if resp:
logger.exception(f'Could not retrieve user info. Response: {resp.text}')
else:
logger.exception('Could not retrieve user info')
logger.exception('Could not retrieve user info')
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='could not fetch user info',

View File

@@ -1,21 +0,0 @@
#
# 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/>.
#

View File

@@ -1,436 +0,0 @@
#
# 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

View File

@@ -1,547 +0,0 @@
#
# 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

View File

@@ -1,123 +0,0 @@
#
# 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

View File

@@ -68,7 +68,6 @@ 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,
)
@@ -288,7 +287,6 @@ 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:
@@ -404,13 +402,6 @@ 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:
@@ -668,7 +659,6 @@ class OrderListExporter(MultiSheetListExporter):
_('External customer ID'),
_('Check-in lists'),
_('Payment providers'),
_('Position order link')
]
# get meta_data labels from first cached event
@@ -722,7 +712,7 @@ class OrderListExporter(MultiSheetListExporter):
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(
get_name_parts_localized(op.attendee_name_parts, k) if op.attendee_name_parts else ''
get_name_parts_localized(op.attendee_name_parts, k)
)
row += [
op.attendee_email,
@@ -813,14 +803,6 @@ 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()

View File

@@ -108,10 +108,8 @@ class WaitingListExporter(ListExporter):
_('Name'),
_('Email'),
_('Phone number'),
_('Product'),
_('Product ID'),
_('Product name'),
_('Variation'),
_('Variation ID'),
_('Event slug'),
_('Event name'),
pgettext_lazy('subevents', 'Date'), # Name of subevent
@@ -148,9 +146,7 @@ class WaitingListExporter(ListExporter):
entry.email,
entry.phone,
str(entry.item) if entry.item else "",
str(entry.item.pk) if entry.item else "",
str(entry.variation) if entry.variation else "",
str(entry.variation.pk) if entry.variation else "",
entry.event.slug,
entry.event.name,
entry.subevent.name if entry.subevent else "",

View File

@@ -58,7 +58,6 @@ from django.urls import reverse
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.text import format_lazy
from django.utils.timezone import get_current_timezone
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries import countries
@@ -84,8 +83,8 @@ from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
)
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS,
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
)
from pretix.base.templatetags.rich_text import rich_text
from pretix.base.timemachine import time_machine_now
@@ -128,13 +127,7 @@ class NamePartsWidget(forms.MultiWidget):
if fname == 'title' and self.titles:
widgets.append(Select(attrs=a, choices=[('', '')] + [(d, d) for d in self.titles[1]]))
elif fname == 'salutation':
widgets.append(Select(
attrs=a,
choices=[
('', '---'),
('empty', '({})'.format(pgettext_lazy("name_salutation", "not specified"))),
] + PERSON_NAME_SALUTATIONS
))
widgets.append(Select(attrs=a, choices=[('', '---'), ('empty', '')] + PERSON_NAME_SALUTATIONS))
else:
widgets.append(self.widget(attrs=a))
super().__init__(widgets, attrs)
@@ -182,15 +175,10 @@ class NamePartsWidget(forms.MultiWidget):
if self.field.required:
these_attrs['required'] = 'required'
these_attrs.pop('data-no-required-attr', None)
autofill_section = self.attrs.get('autocomplete', '')
autofill_by_name_scheme = self.autofill_map.get(self.scheme['fields'][i][0], 'off')
if autofill_by_name_scheme == "off" or autofill_section.strip() == "off":
these_attrs['autocomplete'] = "off"
else:
these_attrs['autocomplete'] = (autofill_section + ' ' + autofill_by_name_scheme).strip()
these_attrs['autocomplete'] = (self.attrs.get('autocomplete', '') + ' ' + self.autofill_map.get(self.scheme['fields'][i][0], 'off')).strip()
these_attrs['data-size'] = self.scheme['fields'][i][2]
these_attrs['aria-label'] = self.scheme['fields'][i][1]
if len(self.widgets) > 1:
these_attrs['aria-label'] = self.scheme['fields'][i][1]
else:
these_attrs = final_attrs
output.append(widget.render(name + '_%s' % i, widget_value, these_attrs, renderer=renderer))
@@ -257,10 +245,7 @@ class NamePartsFormField(forms.MultiValueField):
d.pop('validators', None)
field = forms.ChoiceField(
**d,
choices=[
('', '---'),
('empty', '({})'.format(pgettext_lazy("name_salutation", "not specified"))),
] + PERSON_NAME_SALUTATIONS
choices=[('', '---'), ('empty', '')] + PERSON_NAME_SALUTATIONS
)
else:
field = forms.CharField(**defaults)
@@ -308,10 +293,7 @@ class WrappedPhonePrefixSelect(Select):
self.initial = "+%d" % prefix
break
choices += get_phone_prefixes_sorted_and_localized()
super().__init__(choices=choices, attrs={
'aria-label': pgettext_lazy('phonenumber', 'International area code'),
'autocomplete': 'tel-country-code',
})
super().__init__(choices=choices, attrs={'aria-label': pgettext_lazy('phonenumber', 'International area code')})
def render(self, name, value, *args, **kwargs):
return super().render(name, value or self.initial, *args, **kwargs)
@@ -334,11 +316,11 @@ class WrappedPhonePrefixSelect(Select):
class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def __init__(self, attrs=None, initial=None):
widgets = (WrappedPhonePrefixSelect(initial), forms.TextInput(attrs={
'aria-label': pgettext_lazy('phonenumber', 'Phone number (without international area code)'),
'autocomplete': 'tel-national',
}))
super(PhoneNumberPrefixWidget, self).__init__(widgets)
attrs = {
'aria-label': pgettext_lazy('phonenumber', 'Phone number (without international area code)')
}
widgets = (WrappedPhonePrefixSelect(initial), forms.TextInput(attrs=attrs))
super(PhoneNumberPrefixWidget, self).__init__(widgets, attrs)
def render(self, name, value, attrs=None, renderer=None):
output = super().render(name, value, attrs, renderer)
@@ -739,7 +721,7 @@ class BaseQuestionsForm(forms.Form):
'data-country-information-url': reverse('js_helpers.states'),
}),
)
c = [('', '---')]
c = [('', pgettext_lazy('address', 'Select state'))]
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
cc = None
state = None
@@ -879,34 +861,10 @@ class BaseQuestionsForm(forms.Form):
attrs['data-min'] = q.valid_date_min.isoformat()
if q.valid_date_max:
attrs['data-max'] = q.valid_date_max.isoformat()
if not help_text:
if q.valid_date_min and q.valid_date_max:
help_text = format_lazy(
'Please enter a date between {min} and {max}.',
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
)
elif q.valid_date_min:
help_text = format_lazy(
'Please enter a date no earlier than {min}.',
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
)
elif q.valid_date_max:
help_text = format_lazy(
'Please enter a date no later than {max}.',
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
)
if initial and initial.answer:
try:
_initial = dateutil.parser.parse(initial.answer).date()
except dateutil.parser.ParserError:
_initial = None
else:
_initial = None
field = forms.DateField(
label=label, required=required,
help_text=help_text,
initial=_initial,
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
widget=DatePickerWidget(attrs),
)
if q.valid_date_min:
@@ -914,50 +872,17 @@ class BaseQuestionsForm(forms.Form):
if q.valid_date_max:
field.validators.append(MaxDateValidator(q.valid_date_max))
elif q.type == Question.TYPE_TIME:
if initial and initial.answer:
try:
_initial = dateutil.parser.parse(initial.answer).time()
except dateutil.parser.ParserError:
_initial = None
else:
_initial = None
field = forms.TimeField(
label=label, required=required,
help_text=help_text,
initial=_initial,
initial=dateutil.parser.parse(initial.answer).time() if initial and initial.answer else None,
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
)
elif q.type == Question.TYPE_DATETIME:
if not help_text:
if q.valid_datetime_min and q.valid_datetime_max:
help_text = format_lazy(
'Please enter a date and time between {min} and {max}.',
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
)
elif q.valid_datetime_min:
help_text = format_lazy(
'Please enter a date and time no earlier than {min}.',
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
)
elif q.valid_datetime_max:
help_text = format_lazy(
'Please enter a date and time no later than {max}.',
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
)
if initial and initial.answer:
try:
_initial = dateutil.parser.parse(initial.answer).astimezone(tz)
except dateutil.parser.ParserError:
_initial = None
else:
_initial = None
field = SplitDateTimeField(
label=label, required=required,
help_text=help_text,
initial=_initial,
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
widget=SplitDateTimePickerWidget(
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
min_date=q.valid_datetime_min,
@@ -1018,19 +943,8 @@ class BaseQuestionsForm(forms.Form):
value.initial = data.get('question_form_data', {}).get(key)
for k, v in self.fields.items():
if isinstance(v.widget, forms.MultiWidget):
for w in v.widget.widgets:
autocomplete = w.attrs.get('autocomplete', '')
if autocomplete.strip() == "off":
w.attrs['autocomplete'] = 'off'
else:
w.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
if v.widget.attrs.get('autocomplete') or k == 'attendee_name_parts':
autocomplete = v.widget.attrs.get('autocomplete', '')
if autocomplete.strip() == "off":
v.widget.attrs['autocomplete'] = 'off'
else:
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + v.widget.attrs.get('autocomplete', '')
def clean(self):
from pretix.base.addressvalidation import \
@@ -1165,7 +1079,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.fields['country'].choices = CachedCountries()
self.fields['country'].widget.attrs['data-country-information-url'] = reverse('js_helpers.states')
c = [('', '---')]
c = [('', pgettext_lazy('address', 'Select state'))]
fprefix = self.prefix + '-' if self.prefix else ''
cc = None
if fprefix + 'country' in self.data:
@@ -1174,19 +1088,16 @@ class BaseInvoiceAddressForm(forms.ModelForm):
cc = str(self.initial['country'])
elif self.instance and self.instance.country:
cc = str(self.instance.country)
state_label = pgettext_lazy('address', 'State')
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
if cc in COUNTRY_STATE_LABEL:
state_label = COUNTRY_STATE_LABEL[cc]
elif fprefix + 'state' in self.data:
self.data = self.data.copy()
del self.data[fprefix + 'state']
self.fields['state'] = forms.ChoiceField(
label=state_label,
label=pgettext_lazy('address', 'State'),
required=False,
choices=c,
widget=forms.Select(attrs={
@@ -1244,11 +1155,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
for k, v in self.fields.items():
if v.widget.attrs.get('autocomplete') or k == 'name_parts':
autocomplete = v.widget.attrs.get('autocomplete', '')
if autocomplete.strip() == "off":
v.widget.attrs['autocomplete'] = 'off'
else:
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + autocomplete
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + v.widget.attrs.get('autocomplete', '')
def clean(self):
from pretix.base.addressvalidation import \

View File

@@ -48,7 +48,7 @@ from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import (
BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate,
PageTemplate, Spacer, Table, TableStyle,
PageTemplate, Paragraph, Spacer, Table, TableStyle,
)
from pretix.base.decimal import round_decimal
@@ -56,9 +56,7 @@ from pretix.base.models import Event, Invoice, Order, OrderPayment
from pretix.base.services.currencies import SOURCE_NAMES
from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import (
FontFallbackParagraph, ThumbnailingImageReader, reshaper,
)
from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper
from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__)
@@ -237,17 +235,16 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
italic='OpenSansIt', boldItalic='OpenSansBI')
for family, styles in get_fonts(event=self.event, pdf_support_required=True).items():
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
if family == self.event.settings.invoice_renderer_font:
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
self.font_regular = family
if 'italic' in styles:
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
if 'bold' in styles:
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
self.font_bold = family + ' B'
if 'italic' in styles:
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
if 'bold' in styles:
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
if 'bolditalic' in styles:
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
if 'bolditalic' in styles:
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
def _normalize(self, text):
# reportlab does not support unicode combination characters
@@ -396,8 +393,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_to_top = 52 * mm
def _draw_invoice_to(self, canvas):
p = FontFallbackParagraph(self._clean_text(self.invoice.address_invoice_to),
style=self.stylesheet['Normal'])
p = Paragraph(self._clean_text(self.invoice.address_invoice_to),
style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
@@ -408,7 +405,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_from_top = 17 * mm
def _draw_invoice_from(self, canvas):
p = FontFallbackParagraph(
p = Paragraph(
self._clean_text(self.invoice.full_invoice_from),
style=self.stylesheet['InvoiceFrom']
)
@@ -526,12 +523,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def shorten(txt):
txt = str(txt)
txt = bleach.clean(txt, tags=set()).strip()
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p = Paragraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height)
while p_size[1] > 2 * self.stylesheet['Normal'].leading:
txt = ' '.join(txt.replace('', '').split()[:-1]) + ''
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p = Paragraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height)
return txt
@@ -557,7 +554,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
else:
p_str = shorten(self.invoice.event.name)
p = FontFallbackParagraph(self._normalize(p_str.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p = Paragraph(self._normalize(p_str.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.event_width, self.event_height)
p_size = p.wrap(self.event_width, self.event_height)
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
@@ -611,7 +608,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _get_intro(self):
story = []
if self.invoice.custom_field:
story.append(FontFallbackParagraph(
story.append(Paragraph(
'{}: {}'.format(
self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)),
self._clean_text(self.invoice.custom_field),
@@ -620,7 +617,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
))
if self.invoice.internal_reference:
story.append(FontFallbackParagraph(
story.append(Paragraph(
self._normalize(pgettext('invoice', 'Customer reference: {reference}').format(
reference=self._clean_text(self.invoice.internal_reference),
)),
@@ -628,30 +625,25 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
))
if self.invoice.invoice_to_vat_id:
story.append(FontFallbackParagraph(
story.append(Paragraph(
self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' +
self._clean_text(self.invoice.invoice_to_vat_id),
self.stylesheet['Normal']
))
if self.invoice.invoice_to_beneficiary:
story.append(FontFallbackParagraph(
story.append(Paragraph(
self._normalize(pgettext('invoice', 'Beneficiary')) + ':<br />' +
self._clean_text(self.invoice.invoice_to_beneficiary),
self.stylesheet['Normal']
))
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(FontFallbackParagraph(
story.append(Paragraph(
self._clean_text(self.invoice.introductory_text, tags=['br']),
self.stylesheet['Normal']
))
story.append(Spacer(1, 5 * mm))
story.append(Spacer(1, 10 * mm))
return story
@@ -660,7 +652,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story = [
NextPageTemplate('FirstPage'),
FontFallbackParagraph(
Paragraph(
self._normalize(
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
else pgettext('invoice', 'Invoice')
@@ -686,17 +678,17 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
]
if has_taxes:
tdata = [(
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']),
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
Paragraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']),
Paragraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']),
Paragraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']),
)]
else:
tdata = [(
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
Paragraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
)]
def _group_key(line):
@@ -718,20 +710,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
)
description = description + "\n" + single_price_line
tdata.append((
FontFallbackParagraph(
Paragraph(
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
str(len(lines)),
localize(tax_rate) + " %",
FontFallbackParagraph(
money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
FontFallbackParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
Paragraph(money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']),
Paragraph(money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']),
))
else:
if len(lines) > 1:
@@ -740,15 +726,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
)
description = description + "\n" + single_price_line
tdata.append((
FontFallbackParagraph(
Paragraph(
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
str(len(lines)),
FontFallbackParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
Paragraph(money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']),
))
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
@@ -756,13 +739,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if has_taxes:
tdata.append([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
Paragraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
money_filter(total, self.invoice.event.currency)
])
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
else:
tdata.append([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
Paragraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
money_filter(total, self.invoice.event.currency)
])
colwidths = [a * doc.width for a in (.65, .20, .15)]
@@ -772,12 +755,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
pending_sum = self.invoice.order.pending_sum
if pending_sum != total:
tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] +
[Paragraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum - total, self.invoice.event.currency)]
)
tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] +
[Paragraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum, self.invoice.event.currency)]
)
@@ -794,12 +777,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
s=Sum('amount')
)['s'] or Decimal('0.00')
tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] +
[Paragraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(giftcard_sum, self.invoice.event.currency)]
)
tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] +
[Paragraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(total - giftcard_sum, self.invoice.event.currency)]
)
@@ -822,7 +805,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Spacer(1, 10 * mm))
if self.invoice.payment_provider_text:
story.append(FontFallbackParagraph(
story.append(Paragraph(
self._normalize(self.invoice.payment_provider_text),
self.stylesheet['Normal']
))
@@ -831,7 +814,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Spacer(1, 3 * mm))
if self.invoice.additional_text:
story.append(FontFallbackParagraph(
story.append(Paragraph(
self._clean_text(self.invoice.additional_text, tags=['br']),
self.stylesheet['Normal']
))
@@ -847,10 +830,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
]
thead = [
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']),
Paragraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']),
Paragraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']),
Paragraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']),
Paragraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']),
''
]
tdata = [thead]
@@ -861,7 +844,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
continue
tax = taxvalue_map[idx]
tdata.append([
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
Paragraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
money_filter(gross - tax, self.invoice.event.currency),
money_filter(gross, self.invoice.event.currency),
money_filter(tax, self.invoice.event.currency),
@@ -880,7 +863,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
table.setStyle(TableStyle(tstyledata))
story.append(Spacer(5 * mm, 5 * mm))
story.append(KeepTogether([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']),
Paragraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']),
table
]))
@@ -897,7 +880,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net = gross - tax
tdata.append([
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
Paragraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
fmt(net), fmt(gross), fmt(tax), ''
])
@@ -906,7 +889,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(KeepTogether([
Spacer(1, height=2 * mm),
FontFallbackParagraph(
Paragraph(
self._normalize(pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, this corresponds to:'
@@ -921,7 +904,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
story.append(Spacer(1, 5 * mm))
story.append(FontFallbackParagraph(self._normalize(
story.append(Paragraph(self._normalize(
pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, the invoice total corresponds to {total}.'
@@ -974,7 +957,7 @@ class Modern1Renderer(ClassicInvoiceRenderer):
self._clean_text(l)
for l in self.invoice.address_invoice_from.strip().split('\n')
]
p = FontFallbackParagraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender'])
p = Paragraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender'])
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
super()._draw_invoice_from(canvas)
@@ -1033,7 +1016,7 @@ class Modern1Renderer(ClassicInvoiceRenderer):
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs)
]
p = FontFallbackParagraph(
p = Paragraph(
self._normalize(date_format(self.invoice.date, "DATE_FORMAT")),
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
)
@@ -1091,7 +1074,7 @@ class Modern1SimplifiedRenderer(Modern1Renderer):
i = []
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
i.append(FontFallbackParagraph(
i.append(Paragraph(
pgettext('invoice', 'Event date: {date_range}').format(
date_range=self.invoice.event.get_date_range_display(),
),

View File

@@ -26,7 +26,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import (
Discount, Item, ItemCategory, Order, Question, Quota, SubEvent, TaxRule,
Voucher, WaitingListEntry,
Voucher,
)
from .logentrytype_registry import ( # noqa
@@ -145,15 +145,3 @@ class TaxRuleLogEntryType(EventLogEntryType):
object_link_viewname = 'control:event.settings.tax.edit'
object_link_argname = 'rule'
content_type = TaxRule
class WaitingListEntryLogEntryType(EventLogEntryType):
object_link_wrapper = '{val}'
object_link_viewname = 'control:event.orders.waitinglist'
content_type = WaitingListEntry
def get_object_link_info(self, logentry) -> Optional[dict]:
info = super().get_object_link_info(logentry)
if info and 'href' in info:
info['href'] += '?status=a&entry=' + str(logentry.content_object.pk)
return info

View File

@@ -1,35 +0,0 @@
#
# 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

View File

@@ -38,7 +38,6 @@ 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
@@ -80,8 +79,6 @@ 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):

View File

@@ -1,41 +0,0 @@
# Generated by Django 4.2.16 on 2025-02-28 13:25
from django.db import migrations, models
def remove_duplicates(apps, schema_editor):
UserKnownLoginSource = apps.get_model("pretixbase", "UserKnownLoginSource")
unique_fields = ["user", "agent_type", "device_type", "os_type", "country"]
duplicates = (
UserKnownLoginSource.objects
.values(*unique_fields)
.order_by()
.annotate(latest_id=models.Max('id'), count=models.Count('id'))
.filter(count__gt=1)
)
for duplicate in duplicates:
(
UserKnownLoginSource.objects
.filter(**{x: duplicate[x] for x in unique_fields})
.exclude(id=duplicate["latest_id"])
.delete()
)
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0277_customerssoclient_require_pkce_and_more"),
]
operations = [
migrations.RunPython(remove_duplicates, migrations.RunPython.noop),
migrations.AlterUniqueTogether(
name="userknownloginsource",
unique_together={
("user", "agent_type", "device_type", "os_type", "country")
},
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 4.2.19 on 2025-03-18 09:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0278_login_source_add_unique_together'),
]
operations = [
migrations.AddField(
model_name='discount',
name='subevent_date_from',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='discount',
name='subevent_date_until',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 4.2.20 on 2025-05-14 14:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0279_discount_event_date_from_discount_event_date_until'),
]
operations = [
migrations.AddField(
model_name='cartposition',
name='max_extend',
field=models.DateTimeField(null=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 4.2.16 on 2025-05-20 11:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0280_cartposition_max_extend"),
]
operations = [
migrations.AddField(
model_name="event",
name="is_remote",
field=models.BooleanField(default=False),
),
]

View File

@@ -1,24 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0281_event_is_remote"),
]
operations = [
migrations.AddField(
model_name="taxrule",
name="default",
field=models.BooleanField(default=False),
),
migrations.AddConstraint(
model_name="taxrule",
constraint=models.UniqueConstraint(
condition=models.Q(("default", True)),
fields=("event",),
name="one_default_per_event",
),
),
]

View File

@@ -1,60 +0,0 @@
# Generated by Django 4.2.17 on 2025-03-28 09:19
from django.core.cache import cache
from django.db import migrations, models
from django.db.models import Count, Exists, OuterRef
def set_default_tax_rate(app, schema_editor):
Event = app.get_model('pretixbase', 'Event')
Event_SettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
TaxRule = app.get_model('pretixbase', 'TaxRule')
# Handling of events with tax_rate_default set
for s in Event_SettingsStore.objects.filter(key="tax_rate_default").iterator():
updated = TaxRule.objects.filter(pk=s.value, event_id=s.object_id).update(default=True)
if updated:
# Delete deprecated settings key
s.delete()
# The default for new events is tax_rule_cancellation=none, but since we do not change behaviour
# for existing events without warning, we create a settings entry that matches the old behaviour.
Event_SettingsStore.objects.get_or_create(
object_id=s.object_id,
key="tax_rule_cancellation",
defaults={"value": "default"},
)
# We do not need to set tax_rule_payment here since "default" is the default
cache.delete('hierarkey_{}_{}'.format('event', s.object_id))
# Handling of events with tax_rate_default not set
for e in Event.objects.only("pk").exclude(Exists(TaxRule.objects.filter(default=True, event_id=OuterRef("pk")))).iterator():
fav_tax_rules = e.tax_rules.annotate(c=Count("item")).order_by("-c", "pk")[:1]
if fav_tax_rules:
fav_tax_rules[0].default = True
fav_tax_rules[0].save()
# Previously, no tax rule was set for payments, so keep it this way
Event_SettingsStore.objects.get_or_create(
object=e,
key="tax_rule_payment",
defaults={"value": "none"},
)
cache.delete('hierarkey_{}_{}'.format('event', e.pk))
# We do not need to set tax_rule_cancellation, as "none" is the new system default
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0282_taxrule_default"),
]
operations = [
migrations.RunPython(
set_default_tax_rate,
migrations.RunPython.noop,
),
]

View File

@@ -1,54 +0,0 @@
# 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')},
},
),
]

View File

@@ -1,46 +0,0 @@
# Generated by Django 4.2.16 on 2025-08-08 09:13
from django.db import migrations, models
from django.db.models import Min
from django.utils.timezone import now
def backfill_voucher_created(apps, schema_editor):
Voucher = apps.get_model("pretixbase", "Voucher")
LogEntry = apps.get_model("pretixbase", "LogEntry")
ContentType = apps.get_model("contenttypes", "ContentType")
ct = None
for v in Voucher.objects.filter(created__isnull=True).iterator():
if not ct:
# "Lazy-loading" to prevent this to be executed on new DBs where the content type does not yet
# exist -- but also no vouchers do
ct = ContentType.objects.get(app_label='pretixbase', model='voucher')
v.created = LogEntry.objects.filter(
content_type=ct,
object_id=v.pk,
).aggregate(m=Min("datetime"))["m"] or now()
v.save(update_fields=["created"])
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0284_ordersyncresult_ordersyncqueue"),
]
operations = [
migrations.AddField(
model_name="voucher",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.RunPython(
backfill_voucher_created,
migrations.RunPython.noop,
),
migrations.AlterField(
model_name="voucher",
name="created",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 4.2.16 on 2025-08-14 09:40
from django.db import migrations
from hierarkey.utils import CleanHierarkeyDuplicates
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0285_voucher_created"),
]
operations = [
CleanHierarkeyDuplicates("GlobalSettingsObject_SettingsStore"),
CleanHierarkeyDuplicates("Organizer_SettingsStore"),
CleanHierarkeyDuplicates("Event_SettingsStore"),
migrations.AlterUniqueTogether(
name="event_settingsstore",
unique_together={("object", "key")},
),
migrations.AlterUniqueTogether(
name="globalsettingsobject_settingsstore",
unique_together={("key",)},
),
migrations.AlterUniqueTogether(
name="organizer_settingsstore",
unique_together={("object", "key")},
),
]

View File

@@ -111,13 +111,6 @@ 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

View File

@@ -57,7 +57,6 @@ 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:
@@ -68,24 +67,9 @@ 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:
@@ -110,10 +94,6 @@ 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:
@@ -128,7 +108,6 @@ 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):
@@ -158,7 +137,6 @@ 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):
@@ -192,7 +170,6 @@ class Variation(ImportColumn):
class InvoiceAddressCompany(ImportColumn):
identifier = 'invoice_address_company'
order_level = True
@property
def verbose_name(self):
@@ -204,8 +181,6 @@ class InvoiceAddressCompany(ImportColumn):
class InvoiceAddressNamePart(ImportColumn):
order_level = True
def __init__(self, event, key, label):
self.key = key
self.label = label
@@ -225,7 +200,6 @@ class InvoiceAddressNamePart(ImportColumn):
class InvoiceAddressStreet(ImportColumn):
identifier = 'invoice_address_street'
order_level = True
@property
def verbose_name(self):
@@ -237,7 +211,6 @@ class InvoiceAddressStreet(ImportColumn):
class InvoiceAddressZip(ImportColumn):
identifier = 'invoice_address_zipcode'
order_level = True
@property
def verbose_name(self):
@@ -249,7 +222,6 @@ class InvoiceAddressZip(ImportColumn):
class InvoiceAddressCity(ImportColumn):
identifier = 'invoice_address_city'
order_level = True
@property
def verbose_name(self):
@@ -262,8 +234,6 @@ 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):
@@ -287,8 +257,6 @@ 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):
@@ -314,7 +282,6 @@ class InvoiceAddressState(ImportColumn):
class InvoiceAddressVATID(ImportColumn):
identifier = 'invoice_address_vat_id'
order_level = True
@property
def verbose_name(self):
@@ -326,7 +293,6 @@ class InvoiceAddressVATID(ImportColumn):
class InvoiceAddressReference(ImportColumn):
identifier = 'invoice_address_internal_reference'
order_level = True
@property
def verbose_name(self):
@@ -414,7 +380,6 @@ 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):
@@ -438,7 +403,6 @@ 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):
@@ -507,7 +471,6 @@ class Locale(ImportColumn):
identifier = 'locale'
verbose_name = gettext_lazy('Order locale')
default_value = None
order_level = True
@property
def initial(self):
@@ -551,7 +514,6 @@ 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:
@@ -578,8 +540,6 @@ 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):
@@ -608,7 +568,6 @@ 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()
@@ -640,8 +599,7 @@ class SeatColumn(ImportColumn):
class Comment(ImportColumn):
identifier = 'comment'
verbose_name = gettext_lazy('Order comment')
order_level = True
verbose_name = gettext_lazy('Comment')
def assign(self, value, order, position, invoice_address, **kwargs):
order.comment = value or ''
@@ -650,7 +608,6 @@ 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
@@ -659,7 +616,6 @@ 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
@@ -740,7 +696,6 @@ class QuestionColumn(ImportColumn):
class CustomerColumn(ImportColumn):
identifier = 'customer'
verbose_name = gettext_lazy('Customer')
order_level = True
def clean(self, value, previous_values):
if value:
@@ -765,7 +720,6 @@ def get_order_import_columns(event):
if event.has_subevents:
default.append(SubeventColumn(event))
default += [
GroupingColumn(event),
EmailColumn(event),
PhoneColumn(event),
ItemColumn(event),

View File

@@ -602,9 +602,6 @@ class UserKnownLoginSource(models.Model):
country = FastCountryField(null=True, blank=True)
last_seen = models.DateTimeField()
class Meta:
unique_together = ('user', 'agent_type', 'device_type', 'os_type', 'country')
class StaffSession(models.Model):
user = models.ForeignKey('User', on_delete=models.PROTECT)

View File

@@ -350,7 +350,6 @@ class Checkin(models.Model):
REASON_BLOCKED = 'blocked'
REASON_UNAPPROVED = 'unapproved'
REASON_INVALID_TIME = 'invalid_time'
REASON_ANNULLED = 'annulled'
REASONS = (
(REASON_CANCELED, _('Order canceled')),
(REASON_INVALID, _('Unknown ticket')),
@@ -365,7 +364,6 @@ class Checkin(models.Model):
(REASON_BLOCKED, _('Ticket blocked')),
(REASON_UNAPPROVED, _('Order not approved')),
(REASON_INVALID_TIME, _('Ticket not valid at this time')),
(REASON_ANNULLED, _('Check-in annulled')),
)
successful = models.BooleanField(

View File

@@ -1,149 +0,0 @@
#
# 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,
}

View File

@@ -36,9 +36,7 @@ from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
PositionInfo = namedtuple('PositionInfo',
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'is_addon_to',
'voucher_discount'])
PositionInfo = namedtuple('PositionInfo', ['item_id', 'subevent_id', 'line_price_gross', 'is_addon_to', 'voucher_discount'])
class Discount(LoggedModel):
@@ -173,17 +171,6 @@ class Discount(LoggedModel):
"access to sold-out quota will still receive the discount."),
)
subevent_date_from = models.DateTimeField(
verbose_name=pgettext_lazy("subevent", "Available for dates starting from"),
null=True,
blank=True,
)
subevent_date_until = models.DateTimeField(
verbose_name=pgettext_lazy("subevent", "Available for dates starting until"),
null=True,
blank=True,
)
# more feature ideas:
# - max_usages_per_order
# - promote_to_user_if_almost_satisfied
@@ -368,15 +355,11 @@ class Discount(LoggedModel):
# First, filter out everything not even covered by our product scope
condition_candidates = [
idx
for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
positions.items()
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
if (
(self.condition_all_products or item_id in limit_products) and
(self.condition_apply_to_addons or not is_addon_to) and
(not self.condition_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
and (not subevent_id or (
self.subevent_date_from is None or subevent_date_from >= self.subevent_date_from)) and (
self.subevent_date_until is None or subevent_date_from <= self.subevent_date_until)
)
]
@@ -386,8 +369,7 @@ class Discount(LoggedModel):
benefit_products = {p.pk for p in self.benefit_limit_products.all()}
benefit_candidates = [
idx
for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
positions.items()
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
if (
item_id in benefit_products and
(self.benefit_apply_to_addons or not is_addon_to) and

View File

@@ -60,7 +60,6 @@ from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext, gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
@@ -172,7 +171,7 @@ class EventMixin:
self.date_to.astimezone(tz), ("D" if short else "l")
)
def get_date_range_display(self, tz=None, force_show_end=False, as_html=False, try_to_show_times=False) -> str:
def get_date_range_display(self, tz=None, force_show_end=False, as_html=False) -> str:
"""
Returns a formatted string containing the start date and the end date
of the event with respect to the current locale and to the ``show_date_to``
@@ -181,48 +180,36 @@ class EventMixin:
tz = tz or self.timezone
if (not self.settings.show_date_to and not force_show_end) or not self.date_to:
df, dt = self.date_from, self.date_from
show_times = try_to_show_times
else:
df, dt = self.date_from, self.date_to
show_times = try_to_show_times and self.settings.show_times and (
# Show times if start and end are on the same day ("08:00-10:00")
dt.astimezone(tz).date() == df.astimezone(tz).date() or
# Show times if start and end are on consecutive days and less than 24h ("23:00-03:00")
(dt.astimezone(tz).date() == df.astimezone(tz).date() + timedelta(days=1) and
dt.astimezone(tz).time() < df.astimezone(tz).time())
)
d = daterange(df.astimezone(tz), dt.astimezone(tz), as_html)
if show_times:
if (not self.settings.show_date_to and not force_show_end) or not self.date_to:
time_str = _date(self.date_from.astimezone(tz), "TIME_FORMAT")
else:
time_str = '{}{}'.format(
_date(self.date_from.astimezone(tz), "TIME_FORMAT"),
_date(self.date_to.astimezone(tz), "TIME_FORMAT"),
)
if as_html:
d = format_html(
d + ' <time datetime="{}" data-timezone="{}" data-time-short>{}</time>',
self.date_from.isoformat(),
str(self.timezone),
time_str,
)
else:
d = d + ' ' + time_str
return d
def get_date_range_display_with_times(self) -> str: # Helper for usage from templates
return self.get_date_range_display(try_to_show_times=True)
def get_date_range_display_with_times_as_html(self) -> str: # Helper for usage from templates
return self.get_date_range_display(try_to_show_times=True, as_html=True)
return daterange(df.astimezone(tz), dt.astimezone(tz), as_html)
def get_date_range_display_as_html(self, tz=None, force_show_end=False) -> str:
return self.get_date_range_display(tz, force_show_end, as_html=True)
def get_time_range_display(self, tz=None, force_show_end=False) -> str:
"""
Returns a formatted string containing the start time and sometimes the end time
of the event with respect to the current locale and to the ``show_date_to``
setting. Dates are not shown. This is usually used in combination with get_date_range_display
"""
tz = tz or self.timezone
show_date_to = self.date_to and (self.settings.show_date_to or force_show_end) and (
# Show date to if start and end are on the same day ("08:00-10:00")
self.date_to.astimezone(tz).date() == self.date_from.astimezone(tz).date() or
# Show date to if start and end are on consecutive days and less than 24h ("23:00-03:00")
(self.date_to.astimezone(tz).date() == self.date_from.astimezone(tz).date() + timedelta(days=1) and
self.date_to.astimezone(tz).time() < self.date_from.astimezone(tz).time())
# Do not show end time if this is a 5-day event because there's no way to make it understandable
)
if show_date_to:
return '{} {}'.format(
_date(self.date_from.astimezone(tz), "TIME_FORMAT"),
_date(self.date_to.astimezone(tz), "TIME_FORMAT"),
)
return _date(self.date_from.astimezone(tz), "TIME_FORMAT")
@property
def timezone(self):
return pytz_deprecation_shim.timezone(self.settings.timezone)
@@ -616,11 +603,6 @@ class Event(EventMixin, LoggedModel):
max_length=200,
verbose_name=_("Location"),
)
is_remote = models.BooleanField(
default=False,
verbose_name=_("This event is remote or partially remote."),
help_text=_("This will be used to let users know if the event is in a different timezone and lets us calculate users local times."),
)
geo_lat = models.FloatField(
verbose_name=_("Latitude"),
null=True, blank=True,
@@ -1084,8 +1066,7 @@ class Event(EventMixin, LoggedModel):
s.product = item_map[s.product_id]
s.save(force_insert=True)
valid_sales_channel_identifers = set(self.organizer.sales_channels.values_list("identifier", flat=True))
skip_settings = {
skip_settings = (
'ticket_secrets_pretix_sig1_pubkey',
'ticket_secrets_pretix_sig1_privkey',
# no longer used, but we still don't need to copy them
@@ -1093,10 +1074,7 @@ class Event(EventMixin, LoggedModel):
'presale_css_checksum',
'presale_widget_css_file',
'presale_widget_css_checksum',
} | {
# Some settings might already exist due to e.g. the timezone being special in the API
s.key for s in self.settings._objects.all()
}
)
settings_to_save = []
for s in other.settings._objects.all():
if s.key in skip_settings:
@@ -1116,11 +1094,13 @@ class Event(EventMixin, LoggedModel):
newname = default_storage.save(fname, fi)
s.value = 'file://' + newname
settings_to_save.append(s)
elif s.key.startswith('payment_') and s.key.endswith('__restrict_to_sales_channels'):
data = other.settings._unserialize(s.value, as_type=list)
data = [ident for ident in data if ident in valid_sales_channel_identifers]
s.value = other.settings._serialize(data)
settings_to_save.append(s)
elif s.key == 'tax_rate_default':
try:
if int(s.value) in tax_map:
s.value = tax_map.get(int(s.value)).pk
settings_to_save.append(s)
except ValueError:
pass
else:
settings_to_save.append(s)
other.settings._objects.bulk_create(settings_to_save)
@@ -1194,10 +1174,6 @@ class Event(EventMixin, LoggedModel):
renderers[pp.identifier] = pp
return renderers
@cached_property
def cached_default_tax_rule(self):
return self.tax_rules.filter(default=True).first()
@cached_property
def ticket_secret_generators(self) -> dict:
"""

View File

@@ -793,7 +793,7 @@ class Item(LoggedModel):
class Meta:
verbose_name = _("Product")
verbose_name_plural = _("Products")
ordering = ("category__position", "category", "position", "pk")
ordering = ("category__position", "category", "position")
def __str__(self):
return str(self.internal_name or self.name)
@@ -821,8 +821,7 @@ class Item(LoggedModel):
def ask_attendee_data(self):
return self.admission and self.personalized
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None,
include_bundled=False, force_fixed_gross_price=False):
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False):
price = price if price is not None else self.default_price
bundled_sum = Decimal('0.00')
@@ -851,7 +850,7 @@ class Item(LoggedModel):
else:
t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address,
override_tax_rate=override_tax_rate, currency=currency or self.event.currency,
subtract_from_gross=bundled_sum, force_fixed_gross_price=force_fixed_gross_price)
subtract_from_gross=bundled_sum)
if bundled_sum:
t.name = "MIXED!"
@@ -1837,7 +1836,7 @@ class Question(LoggedModel):
))
llen = len(answer.split(','))
elif all(isinstance(o, QuestionOption) for o in answer):
return answer
return o
else:
l_ = list(self.options.filter(
Q(pk__in=[a for a in answer if isinstance(a, int) or a.isdigit()]) |
@@ -1916,34 +1915,6 @@ class Question(LoggedModel):
if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.'))
def clean(self):
if self.valid_date_max and self.valid_date_min and self.valid_date_min > self.valid_date_max:
raise ValidationError(_("The maximum date must not be before the minimum value."))
if self.valid_datetime_max and self.valid_datetime_min and self.valid_datetime_min > self.valid_datetime_max:
raise ValidationError(_("The maximum date must not be before the minimum value."))
if self.valid_number_max and self.valid_number_min and self.valid_number_min > self.valid_number_max:
raise ValidationError(_("The maximum value must not be lower than the minimum value."))
super().clean()
def clean_type_change(self, old_type, new_type):
if old_type == new_type:
return True
if not self.pk or not self.answers.exists():
return True
if new_type == self.TYPE_TEXT and old_type != self.TYPE_FILE:
# All types can be converted to text except file
return True
if new_type == self.TYPE_STRING and old_type not in (self.TYPE_TEXT, self.TYPE_FILE):
# All types can be converted to string except text or file
return True
if new_type == self.TYPE_CHOICE_MULTIPLE and old_type == self.TYPE_CHOICE:
# Single-choice can be converted to multiple choice without loss
return True
raise ValidationError(
_("The system already contains answers to this question that are not compatible with changing the "
"type of question without data loss. Consider hiding this question and creating a new one instead.")
)
class QuestionOption(models.Model):
question = models.ForeignKey('Question', related_name='options', on_delete=models.CASCADE)

View File

@@ -1199,8 +1199,6 @@ class Order(LockModel, LoggedModel):
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
'attach_ical': attach_ical,
'attach_other_files': attach_other_files,
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
}
)
@@ -1821,7 +1819,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``, ``pending`` or ``canceled``
Marks the order as failed and sets info to ``info``, but only if the order is in ``created`` or ``pending``
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,11 +1827,7 @@ 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 in (
OrderPayment.PAYMENT_STATE_CONFIRMED,
OrderPayment.PAYMENT_STATE_FAILED,
OrderPayment.PAYMENT_STATE_REFUNDED
):
if locked_instance.state not in (OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING):
# Race condition detected, this payment is already confirmed
logger.info('Failed payment {} but ignored due to likely race condition.'.format(
self.full_id,
@@ -2377,17 +2371,17 @@ class OrderFee(models.Model):
self.fee_type, self.value
)
def _calculate_tax(self, tax_rule=None, invoice_address=None):
def _calculate_tax(self, tax_rule=None):
if tax_rule:
self.tax_rule = tax_rule
try:
ia = invoice_address or self.order.invoice_address
ia = self.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rule_payment == "default":
self.tax_rule = self.order.event.cached_default_tax_rule
if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rate_default:
self.tax_rule = self.order.event.settings.tax_rate_default
if self.tax_rule:
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
@@ -2863,8 +2857,6 @@ class OrderPosition(AbstractPosition):
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
'attach_ical': attach_ical,
'attach_other_files': attach_other_files,
'attach_cached_files': [],
}
)
@@ -3102,10 +3094,7 @@ class CartPosition(AbstractPosition):
verbose_name=_("Expiration date"),
db_index=True
)
max_extend = models.DateTimeField(
verbose_name=_("Limit for extending expiration date"),
null=True
)
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2, default=Decimal('0.00'),
verbose_name=_('Tax rate')
@@ -3314,24 +3303,6 @@ 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,

View File

@@ -377,20 +377,9 @@ class TaxRule(LoggedModel):
'if configured above.'),
)
custom_rules = models.TextField(blank=True, null=True)
default = models.BooleanField(
verbose_name=_('Default'),
default=False,
)
class Meta:
ordering = ('event', 'rate', 'id')
constraints = [
models.UniqueConstraint(
fields=["event"],
condition=models.Q(default=True),
name="one_default_per_event",
),
]
class SaleNotAllowed(Exception):
pass
@@ -405,7 +394,7 @@ class TaxRule(LoggedModel):
and not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists()
and not self.event.items.filter(tax_rule=self).exists()
and not (self.default and self.event.tax_rules.filter(~models.Q(pk=self.pk)).exists())
and self.event.settings.tax_rate_default != self
)
@classmethod

View File

@@ -174,9 +174,6 @@ class Voucher(LoggedModel):
('percent', _('Reduce product price by (%)')),
)
created = models.DateTimeField(
auto_now_add=True,
)
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,

View File

@@ -218,6 +218,7 @@ class WaitingListEntry(LoggedModel):
'waitinglistentry': self.pk,
'subevent': self.subevent.pk if self.subevent else None,
}, user=user, auth=auth)
self.log_action('pretix.event.orders.waitinglist.voucher_assigned', user=user, auth=auth)
self.voucher = v
self.save()
@@ -233,7 +234,6 @@ class WaitingListEntry(LoggedModel):
),
user=user,
auth=auth,
log_entry_type='pretix.event.orders.waitinglist.voucher_assigned',
)
def send_mail(self, subject: Union[str, LazyI18nString], template: Union[str, LazyI18nString],
@@ -286,8 +286,6 @@ class WaitingListEntry(LoggedModel):
'subject': subject,
'message': email_content,
'recipient': recipient,
'attach_other_files': attach_other_files,
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
}
)

View File

@@ -43,6 +43,7 @@ from zoneinfo import ZoneInfo
from django import forms
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.db import transaction
from django.dispatch import receiver
@@ -92,73 +93,15 @@ class PaymentProviderForm(Form):
cleaned_data = super().clean()
for k, v in self.fields.items():
val = cleaned_data.get(k)
if hasattr(v, '_required') and v._required and not val:
if v._required and not val:
self.add_error(k, _('This field is required.'))
return cleaned_data
class GiftCardPaymentForm(PaymentProviderForm):
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
self.testmode = kwargs.pop('testmode')
self.positions = kwargs.pop('positions')
self.used_cards = kwargs.pop('used_cards')
super().__init__(*args, **kwargs)
def clean(self):
cleaned_data = super().clean()
if "code" not in cleaned_data:
return cleaned_data
code = cleaned_data["code"].strip()
msg = ""
for p in self.positions:
if p.item.issue_giftcard:
msg = _("You cannot pay with gift cards when buying a gift card.")
self.add_error('code', msg)
return cleaned_data
try:
event = self.event
gc = event.organizer.accepted_gift_cards.get(
secret=code
)
if gc.currency != event.currency:
msg = _("This gift card does not support this currency.")
elif gc.testmode and not self.testmode:
msg = _("This gift card can only be used in test mode.")
elif not gc.testmode and self.testmode:
msg = _("Only test gift cards can be used in test mode.")
elif gc.expires and gc.expires < time_machine_now():
msg = _("This gift card is no longer valid.")
elif gc.value <= Decimal("0.00"):
msg = _("All credit on this gift card has been used.")
if msg:
self.add_error('code', msg)
return cleaned_data
if gc.pk in self.used_cards:
self.add_error('code', _("This gift card is already used for your payment."))
return cleaned_data
except GiftCard.DoesNotExist:
if event.vouchers.filter(code__iexact=code).exists():
msg = _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
"the product selection.")
self.add_error('code', msg)
else:
msg = _("This gift card is not known.")
self.add_error('code', msg)
except GiftCard.MultipleObjectsReturned:
msg = _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")
self.add_error('code', msg)
return cleaned_data
class BasePaymentProvider:
"""
This is the base class for all payment providers.
"""
payment_form_template_name = 'pretixpresale/event/checkout_payment_form_default.html'
def __init__(self, event: Event):
self.event = event
@@ -387,18 +330,18 @@ class BasePaymentProvider:
label=_('Enable payment method'),
required=False,
)),
('_availability_start',
RelativeDateField(
label=_('Available from'),
help_text=_('Users will not be able to choose this payment provider before the given date.'),
required=False,
)),
('_availability_date',
RelativeDateField(
label=_('Available until'),
help_text=_('Users will not be able to choose this payment provider after the given date.'),
required=False,
)),
('_availability_start',
RelativeDateField(
label=_('Available from'),
help_text=_('Users will not be able to choose this payment provider before the given date.'),
required=False,
)),
('_total_min',
forms.DecimalField(
label=_('Minimum order total'),
@@ -689,6 +632,11 @@ class BasePaymentProvider:
the ``_restrict_countries`` and ``_restrict_to_sales_channels`` setting.
:param total: The total value without the payment method fee, after taxes.
.. versionchanged:: 1.17.0
The ``total`` parameter has been added. For backwards compatibility, this method is called again
without this parameter if it raises a ``TypeError`` on first try.
"""
timing = self._is_available_by_time(cart_id=get_or_create_cart_id(request))
pricing = True
@@ -746,7 +694,7 @@ class BasePaymentProvider:
:param order: Only set when this is a change to a new payment method for an existing order.
"""
form = self.payment_form(request)
template = get_template(self.payment_form_template_name)
template = get_template('pretixpresale/event/checkout_payment_form_default.html')
ctx = {'request': request, 'form': form}
return template.render(ctx)
@@ -1360,9 +1308,6 @@ class OffsettingProvider(BasePaymentProvider):
def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:
return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders']))
def refund_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:
return self.payment_control_render(request, payment)
class GiftCardPayment(BasePaymentProvider):
identifier = "giftcard"
@@ -1370,49 +1315,6 @@ class GiftCardPayment(BasePaymentProvider):
multi_use_supported = True
execute_payment_needs_user = False
verbose_name = _("Gift card")
payment_form_class = GiftCardPaymentForm
payment_form_template_name = 'pretixcontrol/giftcards/checkout.html'
def payment_form(self, request: HttpRequest) -> Form:
# Unfortunately, in payment_form we do not know if we're in checkout
# or in an existing order. But we need to do the validation logic in the
# form to get the error messages in the right places for accessbility :-(
if 'checkout' in request.resolver_match.url_name:
cs = cart_session(request)
used_cards = [
p.get('info_data', {}).get('gift_card')
for p in cs.get('payments', [])
if p.get('info_data', {}).get('gift_card')
]
positions = get_cart(request)
testmode = self.event.testmode
else:
used_cards = []
order = self.event.orders.get(code=request.resolver_match.kwargs["order"])
positions = order.positions.all()
testmode = order.testmode
form = self.payment_form_class(
event=self.event,
used_cards=used_cards,
positions=positions,
testmode=testmode,
data=(request.POST if request.method == 'POST' and request.POST.get("payment") == self.identifier else None),
prefix='payment_%s' % self.identifier,
initial={
k.replace('payment_%s_' % self.identifier, ''): v
for k, v in request.session.items()
if k.startswith('payment_%s_' % self.identifier)
}
)
form.fields = self.payment_form_fields
for k, v in form.fields.items():
v._required = v.required
v.required = False
v.widget.is_required = False
return form
@property
def public_name(self) -> str:
@@ -1445,19 +1347,6 @@ class GiftCardPayment(BasePaymentProvider):
f.move_to_end("_enabled", last=False)
return f
@property
def payment_form_fields(self):
fields = [
(
"code",
forms.CharField(
label=_("Gift card code"),
required=True,
),
),
]
return OrderedDict(fields)
@property
def test_mode_message(self) -> str:
return _("In test mode, only test cards will work.")
@@ -1468,6 +1357,11 @@ class GiftCardPayment(BasePaymentProvider):
def order_change_allowed(self, order: Order) -> bool:
return super().order_change_allowed(order) and self.event.organizer.has_gift_cards
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
return get_template('pretixcontrol/giftcards/checkout.html').render({
'request': request,
})
def checkout_confirm_render(self, request, order=None, info_data=None) -> str:
return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({
'info_data': info_data,
@@ -1535,6 +1429,21 @@ class GiftCardPayment(BasePaymentProvider):
def _add_giftcard_to_cart(self, cs, gc):
from pretix.base.services.cart import add_payment_to_cart_session
if gc.currency != self.event.currency:
raise ValidationError(_("This gift card does not support this currency."))
if gc.testmode and not self.event.testmode:
raise ValidationError(_("This gift card can only be used in test mode."))
if not gc.testmode and self.event.testmode:
raise ValidationError(_("Only test gift cards can be used in test mode."))
if gc.expires and gc.expires < time_machine_now():
raise ValidationError(_("This gift card is no longer valid."))
if gc.value <= Decimal("0.00"):
raise ValidationError(_("All credit on this gift card has been used."))
for p in cs.get('payments', []):
if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk:
raise ValidationError(_("This gift card is already used for your payment."))
add_payment_to_cart_session(
cs,
self,
@@ -1546,32 +1455,73 @@ class GiftCardPayment(BasePaymentProvider):
)
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]:
form = self.payment_form(request)
if not form.is_valid():
return False
for p in get_cart(request):
if p.item.issue_giftcard:
messages.error(request, _("You cannot pay with gift cards when buying a gift card."))
return
gc = self.event.organizer.accepted_gift_cards.get(
secret=form.cleaned_data["code"]
)
cs = cart_session(request)
self._add_giftcard_to_cart(cs, gc)
return True
try:
gc = self.event.organizer.accepted_gift_cards.get(
secret=request.POST.get("giftcard").strip()
)
cs = cart_session(request)
try:
self._add_giftcard_to_cart(cs, gc)
return True
except ValidationError as e:
messages.error(request, str(e.message))
return
except GiftCard.DoesNotExist:
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists():
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
"the product selection."))
else:
messages.error(request, _("This gift card is not known."))
except GiftCard.MultipleObjectsReturned:
messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event."))
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str, None]:
form = self.payment_form(request)
if not form.is_valid():
return False
gc = self.event.organizer.accepted_gift_cards.get(
secret=form.cleaned_data["code"]
)
payment.info_data = {
'gift_card': gc.pk,
'gift_card_secret': gc.secret,
'retry': True
}
payment.amount = min(payment.amount, gc.value)
payment.save()
return True
for p in payment.order.positions.all():
if p.item.issue_giftcard:
messages.error(request, _("You cannot pay with gift cards when buying a gift card."))
return
try:
gc = self.event.organizer.accepted_gift_cards.get(
secret=request.POST.get("giftcard").strip()
)
if gc.currency != self.event.currency:
messages.error(request, _("This gift card does not support this currency."))
return
if gc.testmode and not payment.order.testmode:
messages.error(request, _("This gift card can only be used in test mode."))
return
if not gc.testmode and payment.order.testmode:
messages.error(request, _("Only test gift cards can be used in test mode."))
return
if gc.expires and gc.expires < time_machine_now():
messages.error(request, _("This gift card is no longer valid."))
return
if gc.value <= Decimal("0.00"):
messages.error(request, _("All credit on this gift card has been used."))
return
payment.info_data = {
'gift_card': gc.pk,
'gift_card_secret': gc.secret,
'retry': True
}
payment.amount = min(payment.amount, gc.value)
payment.save()
return True
except GiftCard.DoesNotExist:
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard").strip()).exists():
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
"the product selection."))
else:
messages.error(request, _("This gift card is not known."))
except GiftCard.MultipleObjectsReturned:
messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event."))
def execute_payment(self, request: HttpRequest, payment: OrderPayment, is_early_special_case=False) -> str:
for p in payment.order.positions.all():

View File

@@ -159,17 +159,8 @@ 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 and bundled products"),
"label": _("Price including add-ons"),
"editor_sample": _("123.45 EUR"),
"evaluate": lambda op, order, event: money_filter(op.price + sum(
p.price

View File

@@ -28,9 +28,6 @@ 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
@@ -209,27 +206,14 @@ 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={'placeholder': lazy(placeholder_datetime_format, str), 'class': 'datetimepicker'}
attrs={'class': 'datetimepicker'}
),
rel_days_number=forms.NumberInput(),
rel_mins_relationto=forms.Select(choices=base_choices),
rel_days_timeofday=forms.TimeInput(
attrs={'placeholder': lazy(placeholder_time_format, str), 'class': 'timepickerfield'}
),
rel_days_timeofday=forms.TimeInput(attrs={'placeholder': _('Time'), 'class': 'timepickerfield'}),
rel_mins_number=forms.NumberInput(),
rel_days_relationto=forms.Select(choices=base_choices),
rel_mins_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),

View File

@@ -32,7 +32,7 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import (
Event, InvoiceAddress, Order, OrderFee, OrderPosition, OrderRefund,
SubEvent, TaxRule, User, WaitingListEntry,
SubEvent, User, WaitingListEntry,
)
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import SendMailException, mail
@@ -40,7 +40,6 @@ from pretix.base.services.orders import (
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
)
from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.services.tax import split_fee_for_taxes
from pretix.celery_app import app
from pretix.helpers import OF_SELF
from pretix.helpers.format import format_map
@@ -269,34 +268,14 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * total
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
if fee:
tax_rule_zero = TaxRule.zero()
if event.settings.tax_rule_cancellation == "default":
fee_values = [(event.cached_default_tax_rule or tax_rule_zero, fee)]
elif event.settings.tax_rule_cancellation == "split":
fee_values = split_fee_for_taxes(positions, fee, event)
else:
fee_values = [(tax_rule_zero, fee)]
try:
ia = o.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
for tax_rule, price in fee_values:
tax_rule = tax_rule or tax_rule_zero
tax = tax_rule.tax(
price, invoice_address=ia, base_price_is="gross"
)
f = OrderFee(
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
value=price,
order=o,
tax_rate=tax.rate,
tax_code=tax.code,
tax_value=tax.tax,
tax_rule=tax_rule,
)
ocm.add_fee(f)
f = OrderFee(
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
value=fee,
order=o,
tax_rule=o.event.settings.tax_rate_default,
)
f._calculate_tax()
ocm.add_fee(f)
ocm.commit()
refund_amount = o.payment_refund_sum - o.total

View File

@@ -45,7 +45,6 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value
from django.db.models.aggregates import Min
from django.dispatch import receiver
from django.utils.timezone import make_aware, now
from django.utils.translation import (
@@ -276,10 +275,7 @@ class CartManager:
}
def __init__(self, event: Event, cart_id: str, sales_channel: SalesChannel,
invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=None):
"""
Creates a new CartManager for an event.
"""
invoice_address: InvoiceAddress=None, widget_data=None, expiry=None):
self.event = event
self.cart_id = cart_id
self.real_now_dt = now()
@@ -290,17 +286,11 @@ class CartManager:
self._subevents_cache = {}
self._variations_cache = {}
self._seated_cache = {}
self._expiry = None
self._explicit_expiry = expiry
self.invoice_address = invoice_address
self._widget_data = widget_data or {}
self._sales_channel = sales_channel
self.num_extended_positions = 0
if reservation_time:
self._reservation_time = reservation_time
else:
self._reservation_time = timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
self._expiry = self.real_now_dt + self._reservation_time
self._max_expiry_extend = self.real_now_dt + (self._reservation_time * 11)
@property
def positions(self):
@@ -315,6 +305,14 @@ class CartManager:
self._seated_cache[item, subevent] = item.seat_category_mappings.filter(subevent=subevent).exists()
return self._seated_cache[item, subevent]
def _calculate_expiry(self):
if self._explicit_expiry:
self._expiry = self._explicit_expiry
else:
self._expiry = self.real_now_dt + timedelta(
minutes=self.event.settings.get('reservation_time', as_type=int)
)
def _check_presale_dates(self):
if self.event.presale_start and time_machine_now(self.real_now_dt) < self.event.presale_start:
raise CartError(error_messages['not_started'])
@@ -331,27 +329,9 @@ class CartManager:
raise CartError(error_messages['payment_ended'])
def _extend_expiry_of_valid_existing_positions(self):
# real_now_dt is initialized at CartManager instantiation, so it's slightly in the past. Add a small
# delta to reduce risk of extending already expired CartPositions.
padded_now_dt = self.real_now_dt + timedelta(seconds=5)
# Make sure we do not extend past the max_extend timestamp, allowing users to extend their valid positions up
# to 11 times the reservation time. If we add new positions to the cart while valid positions exist, the new
# positions' reservation will also be limited to max_extend of the oldest position.
# Only after all positions expire, an ExtendOperation may reset max_extend to another 11x reservation_time.
max_extend_existing = self.positions.filter(expires__gt=padded_now_dt).aggregate(m=Min('max_extend'))['m']
if max_extend_existing:
self._expiry = min(self._expiry, max_extend_existing)
self._max_expiry_extend = max_extend_existing
# Extend this user's cart session to ensure all items in the cart expire at the same time
# We can extend the reservation of items which are not yet expired without risk
if self._expiry > padded_now_dt:
self.num_extended_positions += self.positions.filter(
expires__gt=padded_now_dt, expires__lt=self._expiry,
).update(
expires=self._expiry,
)
self.positions.filter(expires__gt=self.real_now_dt).update(expires=self._expiry)
def _delete_out_of_timeframe(self):
err = None
@@ -1266,7 +1246,6 @@ class CartManager:
item=op.item,
variation=op.variation,
expires=self._expiry,
max_extend=self._max_expiry_extend,
cart_id=self.cart_id,
voucher=op.voucher,
addon_to=op.addon_to if op.addon_to else None,
@@ -1315,9 +1294,7 @@ class CartManager:
event=self.event,
item=b.item,
variation=b.variation,
expires=self._expiry,
max_extend=self._max_expiry_extend,
cart_id=self.cart_id,
expires=self._expiry, cart_id=self.cart_id,
voucher=None,
addon_to=cp,
subevent=b.subevent,
@@ -1344,14 +1321,12 @@ class CartManager:
op.position.delete()
elif available_count == 1:
op.position.expires = self._expiry
op.position.max_extend = self._max_expiry_extend
op.position.listed_price = op.listed_price
op.position.price_after_voucher = op.price_after_voucher
# op.position.price will be updated by recompute_final_prices_and_taxes()
if op.position.pk not in deleted_positions:
try:
op.position.save(force_update=True, update_fields=['expires', 'max_extend', 'listed_price', 'price_after_voucher'])
self.num_extended_positions += 1
op.position.save(force_update=True, update_fields=['expires', 'listed_price', 'price_after_voucher'])
except DatabaseError:
# Best effort... The position might have been deleted in the meantime!
pass
@@ -1423,8 +1398,7 @@ class CartManager:
self.event,
self._sales_channel.identifier,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in positions
]
)
@@ -1441,11 +1415,14 @@ class CartManager:
def commit(self):
self._check_presale_dates()
self._check_max_cart_size()
self._calculate_expiry()
err = self._delete_out_of_timeframe()
err = self.extend_expired_positions() or err
err = err or self._check_min_per_voucher()
self.real_now_dt = now()
self._extend_expiry_of_valid_existing_positions()
err = self._perform_operations() or err
self.recompute_final_prices_and_taxes()
@@ -1534,10 +1511,7 @@ def get_fees(event, request, total, invoice_address, payments, positions):
total_remaining -= to_pay
if payment_fee:
if event.settings.tax_rule_payment == "default":
payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero()
else:
payment_fee_tax_rule = TaxRule.zero()
payment_fee_tax_rule = event.settings.tax_rate_default or TaxRule.zero()
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address)
fees.append(OrderFee(
fee_type=OrderFee.FEE_TYPE_PAYMENT,
@@ -1657,31 +1631,6 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
raise CartError(error_messages['busy'])
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> dict:
"""
Resets the expiry time of a cart to the configured reservation time of this event.
Limited to 11x the reservation time.
:param event: The event ID in question
:param cart_id: The cart ID of the cart to modify
"""
with language(locale), time_machine_now_assigned(override_now_dt):
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm.commit()
return {"success": cm.num_extended_positions, "expiry": cm._expiry, "max_expiry_extend": cm._max_expiry_extend}
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
raise CartError(error_messages['busy'])
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None:

View File

@@ -120,8 +120,7 @@ class CrossSellingService:
self.event,
self.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
bool(cp.addon_to), cp.is_bundled,
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled,
cp.listed_price - cp.price_after_voucher)
for cp in self.cartpositions
],

View File

@@ -1,85 +0,0 @@
#
# 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)

View File

@@ -531,7 +531,7 @@ def send_invoices_to_organizer(sender, **kwargs):
if i.event.settings.invoice_email_organizer:
with language(i.event.settings.locale):
mail(
email=[e.strip() for e in i.event.settings.invoice_email_organizer.split(",")],
email=i.event.settings.invoice_email_organizer,
subject=_('New invoice: {number}').format(number=i.number),
template=LazyI18nString.from_gettext(_(
'Hello,\n\n'

View File

@@ -96,19 +96,12 @@ class SendMailException(Exception):
def clean_sender_name(sender_name: str) -> str:
# Even though we try to properly escape sender names, some characters seem to cause problems when the escaping
# fails due to some forwardings, etc.
# Emails with @ in their sender name are rejected by some mailservers (e.g. Microsoft) because it looks like
# a phishing attempt.
sender_name = sender_name.replace("@", " ")
# Emails with : in their sender name are treated by Microsoft like emails with no From header at all, leading
# to a higher spam likelihood.
sender_name = sender_name.replace(":", " ")
# Emails with , in their sender name look like multiple senders
sender_name = sender_name.replace(",", "")
# Emails with " in their sender name could be escaped, but somehow create issues in reality
sender_name = sender_name.replace("\"", "")
# Emails with excessively long sender names are rejected by some mailservers
if len(sender_name) > 75:

View File

@@ -89,9 +89,6 @@ 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
@@ -100,28 +97,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
lock_seats = []
for i, record in enumerate(data):
try:
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)
}
if order is None or settings['orders'] == 'many':
order = Order(
event=event,
testmode=settings['testmode'],
@@ -132,12 +108,6 @@ 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 = {}
@@ -145,24 +115,13 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
position.assign_pseudonymization_id()
for c in cols:
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)
c.assign(record.get(c.identifier), 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 + 1, message=str(e))
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
)
try:

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