Compare commits

..
Author SHA1 Message Date
a554a742be Apply suggestions from code review
Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2026-05-22 16:38:38 +02:00
Mira Weller 50a5189626 Fix if condition 2026-05-22 16:37:46 +02:00
Mira Weller 6f74fe293d Add logging 2026-05-22 16:20:40 +02:00
Mira Weller 7554193e2a Remove duplicated csrftoken cookies
Due to a Safari bug, in some browser, two csrftoken cookies with different values
exist: one unpartitioned, one partitioned ("CHIPS"). This function generates an
additional Set-Cookie header to get rid of the unpartitioned one.

As Django usually only allows one Set-Cookie header per cookie name, we
need to manually create a cookie 'Morsel' for the deletion and store it
in the HttpResponse's cookie dictionary under a different name, so it is
not overwritten by the actual, correct Set-Cookie header. This works
because the code in django.core.handlers.wsgi/asgi, that generates the
actual Set-Cookie headers, only iterates over cookie.values(), ignoring
the keys.
2026-05-22 16:12:10 +02:00
452 changed files with 582631 additions and 594986 deletions
+3 -3
View File
@@ -10,9 +10,9 @@ tests:
- cd src - cd src
- python manage.py check - python manage.py check
- make all compress - make all compress
- PRETIX_CONFIG_FILE=tests/ci_sqlite.cfg py.test -n 3 tests --ignore=tests/e2e --maxfail=100 - PRETIX_CONFIG_FILE=tests/ci_sqlite.cfg py.test -n 3 tests --maxfail=100
except: except:
- '/^v.*$/' - pypi
pypi: pypi:
stage: release stage: release
image: image:
@@ -35,7 +35,7 @@ pypi:
- twine check dist/* - twine check dist/*
- twine upload dist/* - twine upload dist/*
only: only:
- '/^v.*$/' - pypi
artifacts: artifacts:
paths: paths:
- src/dist/ - src/dist/
+2 -1
View File
@@ -57,7 +57,8 @@ COPY vite.config.ts /pretix/vite.config.ts
RUN pip3 install -U \ RUN pip3 install -U \
pip \ pip \
setuptools && \ setuptools \
wheel && \
cd /pretix && \ cd /pretix && \
PRETIX_DOCKER_BUILD=TRUE pip3 install \ PRETIX_DOCKER_BUILD=TRUE pip3 install \
-e ".[memcached]" \ -e ".[memcached]" \
+1 -1
View File
@@ -192,7 +192,7 @@ Cart position endpoints
* ``attendee_email`` (optional) * ``attendee_email`` (optional)
* ``subevent`` (optional) * ``subevent`` (optional)
* ``expires`` (optional) * ``expires`` (optional)
* ``includes_tax`` (optional, **DEPRECATED**, do not use, will be removed) * ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
* ``sales_channel`` (optional) * ``sales_channel`` (optional)
* ``voucher`` (optional, expect a voucher code) * ``voucher`` (optional, expect a voucher code)
* ``addons`` (optional, expect a list of nested objects of cart positions) * ``addons`` (optional, expect a list of nested objects of cart positions)
+1 -10
View File
@@ -46,14 +46,12 @@ Checking a ticket in
this request twice with the same nonce, the second request will also succeed but will always 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 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. allows for a certain level of idempotency and enables you to re-try after a connection failure.
:<json string exchange_medium_type: To perform an exchange to a reusable medium, pass the type of the new reusable medium
:<json string exchange_medium_identifier: To perform an exchange to a reusable media, pass the identifier of the new medium
:<json boolean use_order_locale: Specifies that pretix should use the customer's language (``locale`` field from the :<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). order) when building texts (currently only the ``reason_explanation`` response field).
Defaults to ``false`` in which case the server will determine the language (currently 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 the event default language, might change in the future with support for the
``Accept-Language`` header). ``Accept-Language`` header).
:>json string status: ``"ok"``, ``"incomplete"``, ``"exchange"``, or ``"error"`` :>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: 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. :>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null.
:>json object position: Copy of the matching order position (if any was found). The contents are the same as the :>json object position: Copy of the matching order position (if any was found). The contents are the same as the
@@ -69,10 +67,6 @@ Checking a ticket in
:>json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found), :>json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found),
including the attributes ``id``, ``name``, ``event``, ``subevent``, and ``include_pending``. including the attributes ``id``, ``name``, ``event``, ``subevent``, and ``include_pending``.
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``. :>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
:>json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``.
:>json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``.
:>json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response
object will not reflect the simulated changes.
**Example request**: **Example request**:
@@ -230,9 +224,6 @@ Checking a ticket in
* ``ambiguous`` - Multiple tickets match scan, rejected. * ``ambiguous`` - Multiple tickets match scan, rejected.
* ``revoked`` - Ticket code has been revoked. * ``revoked`` - Ticket code has been revoked.
* ``unapproved`` - Order has not yet been approved. * ``unapproved`` - Order has not yet been approved.
* ``already_exchanged`` - Ticket already has been exchanged for a reusable medium that must now be used for check-in.
* ``medium_invalid`` - Reusable medium identifier given was not found or is not valid.
* ``medium_exists`` - Reusable medium identifier already exists, but expected to be new.
* ``error`` - Internal error. * ``error`` - Internal error.
In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation`` In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation``
+2 -7
View File
@@ -351,8 +351,7 @@ Endpoints
:<json boolean error_reason: One of ``canceled``, ``invalid``, ``unpaid``, ``product``, ``rules``, ``revoked``, :<json boolean error_reason: One of ``canceled``, ``invalid``, ``unpaid``, ``product``, ``rules``, ``revoked``,
``incomplete``, ``already_redeemed``, ``blocked``, ``invalid_time``, or ``error``. Required. ``incomplete``, ``already_redeemed``, ``blocked``, ``invalid_time``, or ``error``. Required.
:<json raw_barcode: The raw barcode or identifier you scanned. Required. :<json raw_barcode: The raw barcode you scanned. Required.
:<json raw_source_type: The type of medium you scanned, defaults to ``barcode``. Optional.
:<json datetime: Date and time of the scan. Optional. :<json datetime: Date and time of the scan. Optional.
:<json type: Type of scan, defaults to ``"entry"``. :<json type: Type of scan, defaults to ``"entry"``.
:<json position: Internal ID of an order position you matched. Optional. :<json position: Internal ID of an order position you matched. Optional.
@@ -603,8 +602,7 @@ Order position endpoints
We no longer recommend using this API if you're building a ticket scanning application, as it has a few design We no longer recommend using this API if you're building a ticket scanning application, as it has a few design
flaws that can lead to `security issues`_ or compatibility issues due to barcode content characters that are not flaws that can lead to `security issues`_ or compatibility issues due to barcode content characters that are not
URL-safe. We recommend to use our new :ref:`check-in API <rest-checkin>` instead. Advanced features like medium URL-safe. We recommend to use our new :ref:`check-in API <rest-checkin>` instead.
exchange are only supported on the new API.
:query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never :query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never
as an ``id``. This should be always set if you are passing through untrusted, scanned as an ``id``. This should be always set if you are passing through untrusted, scanned
@@ -743,9 +741,6 @@ Order position endpoints
* ``ambiguous`` - Multiple tickets match scan, rejected. * ``ambiguous`` - Multiple tickets match scan, rejected.
* ``revoked`` - Ticket code has been revoked. * ``revoked`` - Ticket code has been revoked.
* ``unapproved`` - Order has not yet been approved. * ``unapproved`` - Order has not yet been approved.
* ``already_exchanged`` - Ticket already has been exchanged for a reusable medium that must now be used for check-in.
* ``medium_invalid`` - Reusable medium identifier given was not found and could not be automatically created.
* ``medium_exists`` - Reusable medium identifier already exists, but expected to be new.
In case of reason ``rules`` or ``invalid_time``, there might be an additional response field ``reason_explanation`` In case of reason ``rules`` or ``invalid_time``, there might be an additional response field ``reason_explanation``
with a human-readable description of the violated rules. However, that field can also be missing or be ``null``. with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.
+1 -1
View File
@@ -131,7 +131,7 @@ allow_waitinglist boolean If ``false``,
product when it is sold out. product when it is sold out.
issue_giftcard boolean If ``true``, buying this product will yield a gift card. issue_giftcard boolean If ``true``, buying this product will yield a gift card.
media_policy string Policy on how to handle reusable media (experimental feature). media_policy string Policy on how to handle reusable media (experimental feature).
Possible values are ``null``, ``"new"``, ``"reuse"``, ``"reuse_or_new"``, ``"append"``, and ``"append_or_new"``. Possible values are ``null``, ``"new"``, ``"reuse"``, and ``"reuse_or_new"``.
media_type string Type of reusable media to work on (experimental feature). See :ref:`rest-reusablemedia` for possible choices. media_type string Type of reusable media to work on (experimental feature). See :ref:`rest-reusablemedia` for possible choices.
show_quota_left boolean Publicly show how many tickets are still available. show_quota_left boolean Publicly show how many tickets are still available.
If this is ``null``, the event default is used. If this is ``null``, the event default is used.
+3 -9
View File
@@ -864,9 +864,6 @@ Generating new secrets
Triggers generation of new ``secret`` and ``web_secret`` attributes for both the order and all order positions. Triggers generation of new ``secret`` and ``web_secret`` attributes for both the order and all order positions.
Ticket secrets of order positions that have been used to issue a gift card can not
be changed. Only the link (``web_secret``) will be changed in this case.
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -898,9 +895,6 @@ Generating new secrets
Triggers generation of a new ``secret`` and ``web_secret`` attribute for a single order position. Triggers generation of a new ``secret`` and ``web_secret`` attribute for a single order position.
Ticket secrets of order positions that have been used to issue a gift card can not
be changed. Only the link (``web_secret``) will be changed in this case.
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -1075,7 +1069,7 @@ Creating orders
* ``valid_from`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product) * ``valid_from`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
* ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product) * ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
* ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected) * ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected)
* ``use_reusable_medium`` (optional, causes the new ticket to be connected to the given reusable medium, identified by its ID) * ``use_reusable_medium`` (optional, causes the new ticket to take over the given reusable medium, identified by its ID)
* ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run) * ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run)
* ``answers`` * ``answers``
@@ -2038,7 +2032,7 @@ Manipulating individual positions
* ``order`` (mandatory, specified as a string mapping to a ``code``) * ``order`` (mandatory, specified as a string mapping to a ``code``)
* ``addon_to`` (optional, specified as an integer mapping to ``positionid`` - the number of the position within the order, see :ref:`_order-position-resource` - of the parent position) * ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` of the parent position)
* ``item`` (mandatory) * ``item`` (mandatory)
@@ -2348,7 +2342,7 @@ otherwise, such as splitting an order or changing fees.
"subevent": 562, "subevent": 562,
"seat": "seat-guid-2", "seat": "seat-guid-2",
"price": "99.99", "price": "99.99",
"addon_to": 1, "addon_to": 12374,
"attendee_name": "Peter", "attendee_name": "Peter",
} }
], ],
+9 -30
View File
@@ -21,16 +21,12 @@ id integer Internal ID of
type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``. type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``.
organizer string Organizer slug of the organizer who "owns" this medium. organizer string Organizer slug of the organizer who "owns" this medium.
identifier string Unique identifier of the medium. The format depends on the ``type``. identifier string Unique identifier of the medium. The format depends on the ``type``.
claim_token string Secret token to claim ownership of the medium (or ``null``)
label string Label to identify the medium, usually something human readable (or ``null``)
active boolean Whether this medium may be used. active boolean Whether this medium may be used.
created datetime Date of creation created datetime Date of creation
updated datetime Date of last modification updated datetime Date of last modification
expires datetime Expiry date (or ``null``) expires datetime Expiry date (or ``null``)
customer string Identifier of a customer account this medium belongs to. customer string Identifier of a customer account this medium belongs to.
linked_orderpositions list of integers Internal IDs of tickets this medium is linked to. linked_orderposition integer Internal ID of a ticket this medium is linked to.
linked_orderposition integer **DEPRECATED.** ID of the ticket the medium is linked to, if it is linked to
only one ticket. ``null``, if the medium is linked to none or multiple tickets.
linked_giftcard integer Internal ID of a gift card this medium is linked to. linked_giftcard integer Internal ID of a gift card this medium is linked to.
info object Additional data, content depends on the ``type``. Consider info object Additional data, content depends on the ``type``. Consider
this internal to the system and don't use it for your own data. this internal to the system and don't use it for your own data.
@@ -43,14 +39,6 @@ Existing media types are:
- ``nfc_uid`` - ``nfc_uid``
- ``nfc_mf0aes`` - ``nfc_mf0aes``
.. versionchanged:: 2026.5
The ``claim_token``, ``label``, ``linked_orderpositions`` attributes have been added, the ``linked_orderposition`` attribute has been
deprecated. Note: To maintain backwards compatibility ``linked_orderposition`` contains the internal ID of the linked order position
if the medium has exactly one order position in ``linked_orderpositions``.
Endpoints Endpoints
--------- ---------
@@ -89,7 +77,6 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -105,13 +92,10 @@ Endpoints
:query string customer: Only show media linked to the given customer. :query string customer: Only show media linked to the given customer.
:query string created_since: Only show media created since a given date. :query string created_since: Only show media created since a given date.
:query string updated_since: Only show media updated since a given date. :query string updated_since: Only show media updated since a given date.
:query integer linked_orderpositions: Only show media linked to the given tickets. Note: you can pass multiple ticket IDs by passing
``linked_orderpositions`` multiple times. Any medium matching any linked orderposition will be returned.
:query integer linked_orderposition: Only show media linked to the given ticket. :query integer linked_orderposition: Only show media linked to the given ticket.
:query integer linked_giftcard: Only show media linked to the given gift card. :query integer linked_giftcard: Only show media linked to the given gift card.
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderpositions"``, :query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``,
``"linked_orderposition"`` (**DEPRECATED**), or ``"customer"``, the respective field will be shown or ``"customer"``, the respective field will be shown as a nested value instead of just an ID.
as a nested value instead of just an ID.
The nested objects are identical to the respective resources, except that order positions The nested objects are identical to the respective resources, except that order positions
will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make
matching easier. The parameter can be given multiple times. matching easier. The parameter can be given multiple times.
@@ -150,7 +134,6 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -208,7 +191,6 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -216,9 +198,9 @@ Endpoints
} }
:param organizer: The ``slug`` field of the organizer to look up a medium for :param organizer: The ``slug`` field of the organizer to look up a medium for
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderpositions`` each will have an attribute of the the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times. can be given multiple times.
:statuscode 201: no error :statuscode 201: no error
@@ -245,7 +227,6 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -270,7 +251,6 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -278,7 +258,7 @@ Endpoints
} }
:param organizer: The ``slug`` field of the organizer to create a medium for :param organizer: The ``slug`` field of the organizer to create a medium for
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
@@ -307,7 +287,7 @@ Endpoints
Content-Length: 94 Content-Length: 94
{ {
"linked_orderpositions": [13, 29] "linked_orderposition": 13
} }
**Example response**: **Example response**:
@@ -328,8 +308,7 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [13, 29], "linked_orderposition": 13,
"linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
"info": {} "info": {}
@@ -337,7 +316,7 @@ Endpoints
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the medium to modify :param id: The ``id`` field of the medium to modify
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
+2 -2
View File
@@ -64,8 +64,8 @@ Backend
.. automodule:: pretix.control.signals .. automodule:: pretix.control.signals
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings, :members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
order_info, order_approve_info, event_settings_widget, oauth_application_registered, order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms,
order_position_buttons, subevent_forms, item_formsets, order_search_filter_q, order_search_forms, subevent_detail_html item_formsets, order_search_filter_q, order_search_forms
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:no-index: :no-index:
+1 -1
View File
@@ -81,7 +81,7 @@ is a python method that emulates a behavior similar to ``reverse``:
If you need to communicate the URL externally, you can use a different method to ensure that it is always an absolute URL: If you need to communicate the URL externally, you can use a different method to ensure that it is always an absolute URL:
.. autofunction:: pretix.multidomain.urlreverse.eventreverse_absolute .. autofunction:: pretix.multidomain.urlreverse.build_absolute_uri
In addition, there is a template tag that works similar to ``url`` but takes an event or organizer object In addition, there is a template tag that works similar to ``url`` but takes an event or organizer object
as its first argument and can be used like this:: as its first argument and can be used like this::
+1 -1
View File
@@ -53,7 +53,7 @@ Working with the code
--------------------- ---------------------
If you do not have a recent installation of ``nodejs``, install it now:: If you do not have a recent installation of ``nodejs``, install it now::
curl -sL https://deb.nodesource.com/setup_24.x | sudo -E bash - curl -sL https://deb.nodesource.com/setup_17.x | sudo -E bash -
sudo apt install nodejs sudo apt install nodejs
To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix:: To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix::
+94 -112
View File
@@ -370,14 +370,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@tybys/wasm-util": "^0.10.2" "@tybys/wasm-util": "^0.10.1"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -427,9 +427,9 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.133.0", "version": "0.129.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -758,9 +758,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -775,9 +775,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -792,9 +792,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -809,9 +809,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -826,9 +826,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -843,16 +843,13 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -863,16 +860,13 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -883,16 +877,13 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -903,16 +894,13 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -923,16 +911,13 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -943,16 +928,13 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -963,9 +945,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -980,9 +962,9 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
@@ -999,9 +981,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1016,9 +998,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2584,9 +2566,9 @@
} }
}, },
"node_modules/immutable": { "node_modules/immutable": {
"version": "5.1.9", "version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -3176,9 +3158,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -3352,9 +3334,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.23", "version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -3371,7 +3353,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.16", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -3628,14 +3610,14 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.3", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.133.0", "@oxc-project/types": "=0.129.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "1.0.0"
}, },
"bin": { "bin": {
"rolldown": "bin/cli.mjs" "rolldown": "bin/cli.mjs"
@@ -3644,27 +3626,27 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-android-arm64": "1.0.0",
"@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.0",
"@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.0",
"@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.0",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
"@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.0",
"@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.0",
"@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.0",
"@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.0",
"@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.0",
"@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.0",
"@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.0",
"@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.0",
"@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.0",
"@rolldown/binding-win32-x64-msvc": "1.0.3" "@rolldown/binding-win32-x64-msvc": "1.0.0"
} }
}, },
"node_modules/rolldown/node_modules/@rolldown/pluginutils": { "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.1", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -4343,9 +4325,9 @@
} }
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4483,17 +4465,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.16", "version": "8.0.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
"postcss": "^8.5.15", "postcss": "^8.5.14",
"rolldown": "1.0.3", "rolldown": "1.0.0",
"tinyglobby": "^0.2.17" "tinyglobby": "^0.2.16"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
+28 -27
View File
@@ -29,33 +29,32 @@ classifiers = [
dependencies = [ dependencies = [
"arabic-reshaper==3.0.1", # Support for Arabic in reportlab "arabic-reshaper==3.0.1", # Support for Arabic in reportlab
"babel", "babel",
"BeautifulSoup4==4.15.*", "BeautifulSoup4==4.14.*",
"bleach==6.4.*", "bleach==6.3.*",
"celery==5.6.*", "celery==5.6.*",
"chardet==5.2.*", "chardet==5.2.*",
"cryptography>=50.0.0", "cryptography>=48.0.0",
"css-inline==0.21.*", "css-inline==0.20.*",
"defusedcsv>=3.0.0", "defusedcsv>=3.0.0",
"dnspython==2.*", "dnspython==2.*",
"Django[argon2]==5.2.*", "Django[argon2]==5.2.*",
"django-bootstrap3==26.2", "django-bootstrap3==26.1",
"django-compressor==4.6.0", "django-compressor==4.6.0",
"django-countries==9.0.*", "django-countries==8.2.*",
"django-filter==26.1", "django-filter==25.1",
"django-formset-js-improved==0.5.0.5", "django-formset-js-improved==0.5.0.5",
"django-formtools==2.7", "django-formtools==2.6.1",
"django-hierarkey==2.0.*,>=2.0.2", "django-hierarkey==2.0.*,>=2.0.1",
"django-hijack==3.7.*", "django-hijack==3.7.*",
"django-i18nfield==1.11.*", "django-i18nfield==1.11.*",
"django-libsass==0.9", "django-libsass==0.9",
"django-localflavor==5.1", "django-localflavor==5.0",
"django-markup", "django-markup",
"django-oauth-toolkit==2.3.*", "django-oauth-toolkit==2.3.*",
"django-otp==1.7.*", "django-otp==1.7.*",
"django-phonenumber-field==8.5.*", "django-phonenumber-field==8.4.*",
"django-querytagger==0.0.3", "django-redis==6.0.*",
"django-redis==7.0.*", "django-scopes==2.0.*",
"django-scopes==2.1.*",
"django-statici18n==2.7.*", "django-statici18n==2.7.*",
"djangorestframework==3.17.*", "djangorestframework==3.17.*",
"dnspython==2.8.*", "dnspython==2.8.*",
@@ -67,7 +66,7 @@ dependencies = [
"kombu==5.6.*", "kombu==5.6.*",
"libsass==0.23.*", "libsass==0.23.*",
"lxml", "lxml",
"markdown==3.10.3", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3. "markdown==3.10.2", # 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 # We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*", "mt-940==4.30.*",
"oauthlib==3.3.*", "oauthlib==3.3.*",
@@ -75,11 +74,11 @@ dependencies = [
"packaging", "packaging",
"paypalrestsdk==1.13.*", "paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*", "paypal-checkout-serversdk==1.0.*",
"PyJWT==2.13.*", "PyJWT==2.12.*",
"phonenumberslite==9.0.*", "phonenumberslite==9.0.*",
"Pillow==12.3.*", "Pillow==12.2.*",
"pretix-plugin-build", "pretix-plugin-build",
"protobuf==7.35.*", "protobuf==7.34.*",
"psycopg2-binary", "psycopg2-binary",
"pycountry", "pycountry",
"pycparser==3.0", "pycparser==3.0",
@@ -92,33 +91,33 @@ dependencies = [
"pyuca", "pyuca",
"qrcode==8.2", "qrcode==8.2",
"redis==7.4.*", "redis==7.4.*",
"reportlab==5.0.*", "reportlab==4.5.*",
"requests==2.34.*", "requests==2.32.*",
"sentry-sdk==2.66.*", "sentry-sdk==2.60.*",
"sepaxml==2.7.*", "sepaxml==2.7.*",
"stripe==7.9.*", "stripe==7.9.*",
"text-unidecode==1.*", "text-unidecode==1.*",
"tlds>=2026072401", "tlds>=2026041800",
"tqdm==4.*", "tqdm==4.*",
"ua-parser==1.0.*", "ua-parser==1.0.*",
"vobject==0.9.*", "vobject==0.9.*",
"webauthn==3.0.*", "webauthn==2.7.*",
"zeep==4.3.*" "zeep==4.3.*"
] ]
[project.optional-dependencies] [project.optional-dependencies]
memcached = ["pylibmc"] memcached = ["pylibmc"]
dev = [ dev = [
"aiohttp==3.14.*", "aiohttp==3.13.*",
"coverage", "coverage",
"coveralls", "coveralls",
"fakeredis==2.37.*", "fakeredis==2.35.*",
"flake8==7.3.*", "flake8==7.3.*",
"freezegun", "freezegun",
"isort==8.0.*", "isort==8.0.*",
"pep8-naming==0.15.*", "pep8-naming==0.15.*",
"potypo", "potypo",
"pytest-asyncio>=1.4.0", "pytest-asyncio>=1.3.0",
"pytest-cache", "pytest-cache",
"pytest-cov", "pytest-cov",
"pytest-django==4.*", "pytest-django==4.*",
@@ -126,7 +125,7 @@ dev = [
"pytest-sugar", "pytest-sugar",
"pytest-xdist==3.8.*", "pytest-xdist==3.8.*",
"pytest-playwright", "pytest-playwright",
"pytest==9.1.*", "pytest==9.0.*",
"playwright", "playwright",
"responses", "responses",
] ]
@@ -140,6 +139,8 @@ build-backend = "backend"
backend-path = ["_build"] backend-path = ["_build"]
requires = [ requires = [
"setuptools", "setuptools",
"setuptools-rust",
"wheel",
"importlib_metadata", "importlib_metadata",
"tomli", "tomli",
] ]
+2 -2
View File
@@ -6,8 +6,8 @@ localecompile:
./manage.py compilemessages ./manage.py compilemessages
localegen: localegen:
./manage.py makemessages --keep-pot --add-location file --ignore "pretix/static/npm_dir/*" $(LNGS) ./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
./manage.py makemessages --keep-pot --add-location file -e js,ts,vue -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS) ./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
staticfiles: npminstall npmbuild jsi18n staticfiles: npminstall npmbuild jsi18n
./manage.py collectstatic --noinput ./manage.py collectstatic --noinput
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2026.8.0.dev0" __version__ = "2026.5.0.dev0"
-2
View File
@@ -104,7 +104,6 @@ ALL_LANGUAGES = [
('gl', _('Galician')), ('gl', _('Galician')),
('el', _('Greek')), ('el', _('Greek')),
('he', _('Hebrew')), ('he', _('Hebrew')),
('hu', _('Hungarian')),
('id', _('Indonesian')), ('id', _('Indonesian')),
('it', _('Italian')), ('it', _('Italian')),
('ja', _('Japanese')), ('ja', _('Japanese')),
@@ -119,7 +118,6 @@ ALL_LANGUAGES = [
('sv', _('Swedish')), ('sv', _('Swedish')),
('es', _('Spanish')), ('es', _('Spanish')),
('es-419', _('Spanish (Latin America)')), ('es-419', _('Spanish (Latin America)')),
('th', _('Thai')),
('tr', _('Turkish')), ('tr', _('Turkish')),
('uk', _('Ukrainian')), ('uk', _('Ukrainian')),
] ]
+1 -25
View File
@@ -20,11 +20,8 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import logging import logging
from datetime import timedelta
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.db import DatabaseError
from django.utils.timezone import now
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from rest_framework import exceptions from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
@@ -33,7 +30,6 @@ from pretix.api.auth.devicesecurity import (
FullAccessSecurityProfile, get_all_security_profiles, FullAccessSecurityProfile, get_all_security_profiles,
) )
from pretix.base.models import Device from pretix.base.models import Device
from pretix.base.models.devices import DeviceLastSeen
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -46,7 +42,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
model = self.get_model() model = self.get_model()
try: try:
with scopes_disabled(): with scopes_disabled():
device = model.objects.select_related('organizer', 'last_seen').get(api_token=key) device = model.objects.select_related('organizer').get(api_token=key)
except model.DoesNotExist: except model.DoesNotExist:
raise exceptions.AuthenticationFailed('Invalid token.') raise exceptions.AuthenticationFailed('Invalid token.')
@@ -57,7 +53,6 @@ class DeviceTokenAuthentication(TokenAuthentication):
logging.warning(f'Connection attempt of revoked device {device.pk}.') logging.warning(f'Connection attempt of revoked device {device.pk}.')
raise exceptions.AuthenticationFailed('Device access has been revoked.') raise exceptions.AuthenticationFailed('Device access has been revoked.')
self._update_last_seen(device)
return AnonymousUser(), device return AnonymousUser(), device
def authenticate(self, request): def authenticate(self, request):
@@ -68,22 +63,3 @@ class DeviceTokenAuthentication(TokenAuthentication):
if not profile.is_allowed(request): if not profile.is_allowed(request):
raise exceptions.PermissionDenied('Request denied by device security profile.') raise exceptions.PermissionDenied('Request denied by device security profile.')
return r return r
def _update_last_seen(self, device: Device):
try:
try:
last_seen_obj = device.last_seen
except DeviceLastSeen.DoesNotExist:
# First request from device, create model, ignore result. Use get_or_create to be safe
# against concurrent create requests
DeviceLastSeen.objects.get_or_create(device=device, last_seen=now())
else:
if now() - last_seen_obj.last_seen < timedelta(seconds=10):
# We don't need to know the last seen info of a device to more precision than this,
# so we can avoid some database writes if the device is bursting a lot of requests.
return
last_seen_obj.last_seen = now()
last_seen_obj.save(update_fields=["last_seen"])
except DatabaseError:
# Do not stop the request from happening
logger.exception("Database error while updating last_seen")
+4 -18
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import logging import logging
from collections import OrderedDict
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -51,18 +52,10 @@ class BaseSecurityProfile:
""" """
raise NotImplementedError() raise NotImplementedError()
@property
def priority(self) -> int:
"""
Priority for ordering, higher will come first.
"""
return 100
class FullAccessSecurityProfile(BaseSecurityProfile): class FullAccessSecurityProfile(BaseSecurityProfile):
identifier = 'full' identifier = 'full'
verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)') verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)')
priority = 1000
def is_allowed(self, request): def is_allowed(self, request):
return True return True
@@ -115,11 +108,8 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'), ('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:checkinrpc.search'),
('GET', 'api-v1:reusablemedium-list'), ('GET', 'api-v1:reusablemedium-list'),
('POST', 'api-v1:reusablemedium-lookup'),
('PATCH', 'api-v1:reusablemedium-detail')
) )
@@ -154,7 +144,6 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'), ('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:checkinrpc.search'),
) )
@@ -191,7 +180,6 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'), ('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:checkinrpc.search'),
) )
@@ -202,15 +190,13 @@ def get_all_security_profiles():
if _ALL_PROFILES: if _ALL_PROFILES:
return _ALL_PROFILES return _ALL_PROFILES
types = [] types = OrderedDict()
for recv, ret in register_device_security_profile.send(None): for recv, ret in register_device_security_profile.send(None):
if isinstance(ret, (list, tuple)): if isinstance(ret, (list, tuple)):
for r in ret: for r in ret:
types.append(r) types[r.identifier] = r
else: else:
types.append(ret) types[ret.identifier] = ret
types.sort(key=lambda el: el.priority, reverse=True)
types = {r.identifier: r for r in types}
_ALL_PROFILES = types _ALL_PROFILES = types
return types return types
-28
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import json import json
import re
from django.db.models import prefetch_related_objects from django.db.models import prefetch_related_objects
from rest_framework import serializers from rest_framework import serializers
@@ -136,30 +135,3 @@ class SalesChannelMigrationMixin:
else: else:
value["sales_channels"] = value["limit_sales_channels"] value["sales_channels"] = value["limit_sales_channels"]
return value return value
class CompatDecimalField(serializers.DecimalField):
"""
Historically, pretix recorded tax rates as decimals with two places. Today, pretix supports tax rates with up to
four places. Since our API outputs decimals with the stored precision, this would have changed the API output from
"19.00" to "19.0000" without warning. While this is semantically the same thing, we need to assume some pretix API
users might run into trouble, either because they treat the value as a string and then map something
(e.g. ``if tax_rate == "19.00"``) or process it with a language where this is a significant difference. For example,
while in Python ``Decimal("19.00") == Decimal("19.0000")`` is true, in Java
``(new BigDecimal("19.00")).equals(new BigDecimal("19.0000"))`` is false and only
``(new BigDecimal("19.00")).compareTo(new BigDecimal("19.0000")) == 0`` is true.
Therefore, we stay backwards compatible by outputting two decimal places *as long as the trailing digits are zero-valued.
"""
regex = re.compile(r"^([0-9]+\.[0-9]{2})0+$")
def to_representation(self, value):
if self.localize:
raise ValueError("localization not supported")
value = super().to_representation(value)
if value and "." not in value:
return f"{value}.00"
if m := self.regex.match(value):
return m.group(1)
return value
-9
View File
@@ -88,20 +88,11 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
nonce = serializers.CharField(required=False, allow_null=True) nonce = serializers.CharField(required=False, allow_null=True)
datetime = serializers.DateTimeField(required=False, allow_null=True) datetime = serializers.DateTimeField(required=False, allow_null=True)
answers = serializers.JSONField(required=False, allow_null=True) answers = serializers.JSONField(required=False, allow_null=True)
exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES)
exchange_medium_identifier = serializers.CharField(required=False)
simulate = serializers.BooleanField(default=False, required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event') self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event')
def validate(self, attrs):
exchange_fields = ["exchange_medium_type", "exchange_medium_identifier"]
if any(attrs.get(k) is None for k in exchange_fields) and not all(attrs.get(k) is None for k in exchange_fields):
raise ValidationError("If you set any of exchange_medium_type or exchange_medium_identifier, you need to set both of them.")
return attrs
class MiniCheckinListSerializer(I18nAwareModelSerializer): class MiniCheckinListSerializer(I18nAwareModelSerializer):
event = serializers.SlugRelatedField(slug_field='slug', read_only=True) event = serializers.SlugRelatedField(slug_field='slug', read_only=True)
+3 -8
View File
@@ -48,7 +48,7 @@ from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
from pretix.api.serializers import ( from pretix.api.serializers import (
CompatDecimalField, CompatibleJSONField, SalesChannelMigrationMixin, CompatibleJSONField, SalesChannelMigrationMixin,
) )
from pretix.api.serializers.fields import PluginsField from pretix.api.serializers.fields import PluginsField
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
@@ -73,7 +73,7 @@ from pretix.base.settings import (
LazyI18nStringList, validate_event_settings, LazyI18nStringList, validate_event_settings,
) )
from pretix.base.signals import api_event_settings_fields from pretix.base.signals import api_event_settings_fields
from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.multidomain.urlreverse import build_absolute_uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -173,7 +173,7 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
) )
def get_event_url(self, event): def get_event_url(self, event):
return eventreverse_absolute(event, 'presale:event.index') return build_absolute_uri(event, 'presale:event.index')
class Meta: class Meta:
model = Event model = Event
@@ -681,7 +681,6 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
required=False, required=False,
allow_null=True, allow_null=True,
) )
rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta: class Meta:
model = TaxRule model = TaxRule
@@ -748,7 +747,6 @@ class EventSettingsSerializer(SettingsSerializer):
'max_items_per_order', 'max_items_per_order',
'reservation_time', 'reservation_time',
'contact_mail', 'contact_mail',
'contact_url',
'show_variations_expanded', 'show_variations_expanded',
'hide_sold_out', 'hide_sold_out',
'meta_noindex', 'meta_noindex',
@@ -873,7 +871,6 @@ class EventSettingsSerializer(SettingsSerializer):
'og_image', 'og_image',
'name_scheme', 'name_scheme',
'reusable_media_active', 'reusable_media_active',
'reusable_media_usage_enforced',
'reusable_media_type_barcode', 'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length', 'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid',
@@ -888,7 +885,6 @@ class EventSettingsSerializer(SettingsSerializer):
readonly_fields = [ readonly_fields = [
# These are read-only since they are currently only settable on organizers, not events # These are read-only since they are currently only settable on organizers, not events
'reusable_media_active', 'reusable_media_active',
'reusable_media_usage_enforced',
'reusable_media_type_barcode', 'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length', 'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid',
@@ -974,7 +970,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid',
'reusable_media_type_nfc_mf0aes', 'reusable_media_type_nfc_mf0aes',
'reusable_media_type_nfc_mf0aes_random_uid', 'reusable_media_type_nfc_mf0aes_random_uid',
'reusable_media_usage_enforced',
'system_question_order', 'system_question_order',
'tax_rule_payment', 'tax_rule_payment',
'tax_rule_cancellation', 'tax_rule_cancellation',
+4 -6
View File
@@ -42,9 +42,7 @@ from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from pretix.api.serializers import ( from pretix.api.serializers import SalesChannelMigrationMixin
CompatDecimalField, SalesChannelMigrationMixin,
)
from pretix.api.serializers.event import MetaDataField from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers.fields import UploadedFileField from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
@@ -278,10 +276,10 @@ class ItemAddOnSerializer(serializers.ModelSerializer):
return value return value
class ItemTaxRateField(CompatDecimalField): class ItemTaxRateField(serializers.Field):
def to_representation(self, i): def to_representation(self, i):
if i.tax_rule: if i.tax_rule:
return super().to_representation(Decimal(i.tax_rule.rate)) return str(Decimal(i.tax_rule.rate))
else: else:
return str(Decimal('0.00')) return str(Decimal('0.00'))
@@ -291,7 +289,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
bundles = InlineItemBundleSerializer(many=True, required=False) bundles = InlineItemBundleSerializer(many=True, required=False)
variations = InlineItemVariationSerializer(many=True, required=False) variations = InlineItemVariationSerializer(many=True, required=False)
program_times = InlineItemProgramTimeSerializer(many=True, required=False) program_times = InlineItemProgramTimeSerializer(many=True, required=False)
tax_rate = ItemTaxRateField(source='*', read_only=True, max_digits=7, decimal_places=4) tax_rate = ItemTaxRateField(source='*', read_only=True)
meta_data = MetaDataField(required=False, source='*') meta_data = MetaDataField(required=False, source='*')
picture = UploadedFileField(required=False, allow_null=True, allowed_types=( picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
'image/png', 'image/jpeg', 'image/gif' 'image/png', 'image/jpeg', 'image/gif'
+12 -54
View File
@@ -66,14 +66,13 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
expand_nested = self.context['request'].query_params.getlist('expand')
if 'linked_giftcard' in expand_nested: if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
if not self.context["can_read_giftcards"]: if not self.context["can_read_giftcards"]:
raise PermissionDenied("No permission to access gift card details.") raise PermissionDenied("No permission to access gift card details.")
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context) self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context)
if 'linked_giftcard.owner_ticket' in expand_nested: if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'):
self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context) self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
else: else:
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField( self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
@@ -82,27 +81,17 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
queryset=self.context['organizer'].issued_gift_cards.all() queryset=self.context['organizer'].issued_gift_cards.all()
) )
# keep linked_orderposition (singular) for backwards compatibility, will be overwritten in self.validate if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField( # Permission Check performed in to_representation
required=False, self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
allow_null=True,
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
)
if 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested:
self.fields['linked_orderpositions'] = NestedOrderPositionSerializer(
many=True,
read_only=True
)
else: else:
self.fields['linked_orderpositions'] = serializers.PrimaryKeyRelatedField( self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
many=True,
required=False, required=False,
allow_null=True, allow_null=True,
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']), queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
) )
if 'customer' in expand_nested: if 'customer' in self.context['request'].query_params.getlist('expand'):
if not self.context["can_read_customers"]: if not self.context["can_read_customers"]:
raise PermissionDenied("No permission to access customer details.") raise PermissionDenied("No permission to access customer details.")
@@ -117,21 +106,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
if 'linked_orderposition' in data:
linked_orderposition = data['linked_orderposition']
# backwards-compatibility
if 'linked_orderpositions' in data:
raise ValidationError({
'linked_orderposition': 'You cannot use linked_orderposition and linked_orderpositions at the same time.'
})
if self.instance and self.instance.linked_orderpositions.count() > 1:
raise ValidationError({
'linked_orderposition': 'There are more than one linked_orderposition. You need to use linked_orderpositions.'
})
data['linked_orderpositions'] = [linked_orderposition] if linked_orderposition else []
del data['linked_orderposition']
if 'type' in data and 'identifier' in data: if 'type' in data and 'identifier' in data:
qs = self.context['organizer'].reusable_media.filter( qs = self.context['organizer'].reusable_media.filter(
identifier=data['identifier'], type=data['type'] identifier=data['identifier'], type=data['type']
@@ -147,28 +121,14 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
r = super().to_representation(instance) r = super().to_representation(instance)
request = self.context.get('request') request = self.context.get('request')
ops = r.get('linked_orderpositions', [])
# late permission evaluations for checks that depend on the actual linked events # late permission evaluations for checks that depend on the actual linked events
expand_nested = self.context['request'].query_params.getlist('expand') expand_nested = self.context['request'].query_params.getlist('expand')
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if ops and 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested: if 'linked_orderposition' in expand_nested:
ops_noperm = [] if instance.linked_orderposition is not None:
for lop in instance.linked_orderpositions.all(): event = instance.linked_orderposition.order.event
event = lop.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request): if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
ops_noperm.append(lop.id) r['linked_orderposition'] = {'id': instance.linked_orderposition.id}
if ops_noperm:
ops = [
{'id': op['id']} if op['id'] in ops_noperm
else op
for op in ops
]
r['linked_orderpositions'] = ops
# add linked_orderposition (singular) for backwards compatibility
if len(ops) < 2:
r['linked_orderposition'] = ops[0] if ops else None
if 'linked_giftcard.owner_ticket' in expand_nested: if 'linked_giftcard.owner_ticket' in expand_nested:
gc = instance.linked_giftcard gc = instance.linked_giftcard
@@ -188,12 +148,10 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
'updated', 'updated',
'type', 'type',
'identifier', 'identifier',
'claim_token',
'label',
'active', 'active',
'expires', 'expires',
'customer', 'customer',
'linked_orderpositions', 'linked_orderposition',
'linked_giftcard', 'linked_giftcard',
'info', 'info',
'notes', 'notes',
+9 -27
View File
@@ -41,7 +41,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from pretix.api.serializers import CompatDecimalField, CompatibleJSONField from pretix.api.serializers import CompatibleJSONField
from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.forms import form_field_to_serializer_field from pretix.api.serializers.forms import form_field_to_serializer_field
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
@@ -52,7 +52,6 @@ from pretix.api.signals import order_api_details, orderposition_api_details
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress, CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress,
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question, InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
@@ -77,7 +76,7 @@ from pretix.base.settings import (
) )
from pretix.base.signals import register_ticket_outputs from pretix.base.signals import register_ticket_outputs
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.multidomain.urlreverse import build_absolute_uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -382,7 +381,6 @@ class PrintLogSerializer(serializers.ModelSerializer):
class FailedCheckinSerializer(I18nAwareModelSerializer): class FailedCheckinSerializer(I18nAwareModelSerializer):
error_reason = serializers.ChoiceField(choices=Checkin.REASONS, required=True, allow_null=False) error_reason = serializers.ChoiceField(choices=Checkin.REASONS, required=True, allow_null=False)
raw_barcode = serializers.CharField(required=True, allow_null=False) raw_barcode = serializers.CharField(required=True, allow_null=False)
raw_source_type = serializers.ChoiceField(choices=[(k, v) for k, v in MEDIA_TYPES.items()], default='barcode')
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none(), required=False, allow_null=True) position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none(), required=False, allow_null=True)
raw_item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True) raw_item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True) raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
@@ -392,7 +390,7 @@ class FailedCheckinSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Checkin model = Checkin
fields = ('error_reason', 'error_explanation', 'raw_barcode', 'raw_item', 'raw_variation', fields = ('error_reason', 'error_explanation', 'raw_barcode', 'raw_item', 'raw_variation',
'raw_subevent', 'raw_source_type', 'nonce', 'datetime', 'type', 'position') 'raw_subevent', 'nonce', 'datetime', 'type', 'position')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -593,7 +591,6 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
country = CompatibleCountryField(source='*') country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False) attendee_name = serializers.CharField(required=False)
plugin_data = OrderPositionPluginDataField(source='*', allow_null=True, read_only=True) plugin_data = OrderPositionPluginDataField(source='*', allow_null=True, read_only=True)
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta: class Meta:
list_serializer_class = OrderPositionListSerializer list_serializer_class = OrderPositionListSerializer
@@ -750,8 +747,6 @@ class OrderPaymentDateField(serializers.DateField):
class OrderFeeSerializer(I18nAwareModelSerializer): class OrderFeeSerializer(I18nAwareModelSerializer):
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta: class Meta:
model = OrderFee model = OrderFee
fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule',
@@ -762,7 +757,7 @@ class PaymentURLField(serializers.URLField):
def to_representation(self, instance: OrderPayment): def to_representation(self, instance: OrderPayment):
if instance.state != OrderPayment.PAYMENT_STATE_CREATED: if instance.state != OrderPayment.PAYMENT_STATE_CREATED:
return None return None
return eventreverse_absolute(instance.order.event, 'presale:event.order.pay', kwargs={ return build_absolute_uri(instance.order.event, 'presale:event.order.pay', kwargs={
'order': instance.order.code, 'order': instance.order.code,
'secret': instance.order.secret, 'secret': instance.order.secret,
'payment': instance.pk, 'payment': instance.pk,
@@ -811,7 +806,7 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
class OrderURLField(serializers.URLField): class OrderURLField(serializers.URLField):
def to_representation(self, instance: Order): def to_representation(self, instance: Order):
return eventreverse_absolute(instance.event, 'presale:event.order', kwargs={ return build_absolute_uri(instance.event, 'presale:event.order', kwargs={
'order': instance.code, 'order': instance.code,
'secret': instance.secret, 'secret': instance.secret,
}) })
@@ -1154,7 +1149,6 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError( raise ValidationError(
{'discount': ['You can only specify a discount if you do the price computation, but price is not set.']} {'discount': ['You can only specify a discount if you do the price computation, but price is not set.']}
) )
return data return data
@@ -1594,7 +1588,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos_data['attendee_name_parts'] = { pos_data['attendee_name_parts'] = {
'_legacy': attendee_name '_legacy': attendee_name
} }
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k not in ('answers', '_quotas', 'use_reusable_medium')}) pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
if simulate: if simulate:
pos.order = order._wrapped pos.order = order._wrapped
else: else:
@@ -1709,25 +1703,15 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answ.options.add(*options) answ.options.add(*options)
if use_reusable_medium: if use_reusable_medium:
if pos.item.media_policy not in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW): use_reusable_medium.linked_orderposition = pos
for op_pk in use_reusable_medium.linked_orderpositions.values_list('pk', flat=True): use_reusable_medium.save(update_fields=['linked_orderposition'])
use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.removed',
data={
'linked_orderposition': op_pk,
}
)
use_reusable_medium.linked_orderpositions.set([pos])
else:
use_reusable_medium.linked_orderpositions.add(pos)
use_reusable_medium.log_action( use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.added', 'pretix.reusable_medium.linked_orderposition.changed',
data={ data={
'by_order': order.code, 'by_order': order.code,
'linked_orderposition': pos.pk, 'linked_orderposition': pos.pk,
} }
) )
use_reusable_medium.touch()
if not simulate: if not simulate:
for cp in delete_cps: for cp in delete_cps:
@@ -1916,7 +1900,6 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
position = LinePositionField(read_only=True) position = LinePositionField(read_only=True)
event_date_from = serializers.DateTimeField(read_only=True, source="period_start") event_date_from = serializers.DateTimeField(read_only=True, source="period_start")
event_date_to = serializers.DateTimeField(read_only=True, source="period_end") event_date_to = serializers.DateTimeField(read_only=True, source="period_end")
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta: class Meta:
model = InvoiceLine model = InvoiceLine
@@ -2000,7 +1983,6 @@ class BlockedTicketSecretSerializer(I18nAwareModelSerializer):
class TransactionSerializer(I18nAwareModelSerializer): class TransactionSerializer(I18nAwareModelSerializer):
order = serializers.SlugRelatedField(slug_field="code", read_only=True) order = serializers.SlugRelatedField(slug_field="code", read_only=True)
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
class Meta: class Meta:
model = Transaction model = Transaction
+6 -10
View File
@@ -27,7 +27,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction from django.db import transaction
from django.db.models import Q from django.db.models import Q
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@@ -58,8 +58,8 @@ from pretix.helpers.permission_migration import (
OLD_TO_NEW_EVENT_COMPAT, OLD_TO_NEW_EVENT_MIGRATION, OLD_TO_NEW_EVENT_COMPAT, OLD_TO_NEW_EVENT_MIGRATION,
OLD_TO_NEW_ORGANIZER_COMPAT, OLD_TO_NEW_ORGANIZER_MIGRATION, OLD_TO_NEW_ORGANIZER_COMPAT, OLD_TO_NEW_ORGANIZER_MIGRATION,
) )
from pretix.helpers.urls import mainreverse_absolute from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.multidomain.urlreverse import build_absolute_uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -71,7 +71,7 @@ class OrganizerSerializer(I18nAwareModelSerializer):
slug = serializers.CharField(read_only=True) slug = serializers.CharField(read_only=True)
def get_organizer_url(self, organizer): def get_organizer_url(self, organizer):
return eventreverse_absolute(organizer, 'presale:organizer.index') return build_absolute_uri(organizer, 'presale:organizer.index')
class Meta: class Meta:
model = Organizer model = Organizer
@@ -492,16 +492,14 @@ class TeamInviteSerializer(serializers.ModelSerializer):
def _send_invite(self, instance): def _send_invite(self, instance):
mail( mail(
instance.email, instance.email,
gettext('You\'ve been invited to join %(organizer)s') % { _('Account invitation'),
'organizer': self.context['organizer'].name,
},
'pretixcontrol/email/invitation.txt', 'pretixcontrol/email/invitation.txt',
{ {
'instance': settings.PRETIX_INSTANCE_NAME, 'instance': settings.PRETIX_INSTANCE_NAME,
'user': self, 'user': self,
'organizer': self.context['organizer'].name, 'organizer': self.context['organizer'].name,
'team': instance.team.name, 'team': instance.team.name,
'url': mainreverse_absolute('control:auth.invite', kwargs={ 'url': build_global_uri('control:auth.invite', kwargs={
'token': instance.token 'token': instance.token
}) })
}, },
@@ -576,7 +574,6 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'customer_accounts_require_login_for_order_access', 'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed', 'invoice_regenerate_allowed',
'contact_mail', 'contact_mail',
'contact_url',
'imprint_url', 'imprint_url',
'organizer_info_text', 'organizer_info_text',
'event_list_type', 'event_list_type',
@@ -608,7 +605,6 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'cookie_consent_dialog_button_yes', 'cookie_consent_dialog_button_yes',
'cookie_consent_dialog_button_no', 'cookie_consent_dialog_button_no',
'reusable_media_active', 'reusable_media_active',
'reusable_media_usage_enforced',
'reusable_media_type_barcode', 'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length', 'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_uid',
+25 -146
View File
@@ -69,10 +69,8 @@ from pretix.base.models import (
from pretix.base.models.orders import PrintLog from pretix.base.models.orders import PrintLog
from pretix.base.permissions import AnyPermissionOf from pretix.base.permissions import AnyPermissionOf
from pretix.base.services.checkin import ( from pretix.base.services.checkin import (
CheckInError, RequiredMediaExchangeError, RequiredQuestionsError, SQLLogic, CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
perform_checkin,
) )
from pretix.base.services.media import perform_media_exchange
from pretix.base.signals import checkin_annulled from pretix.base.signals import checkin_annulled
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
@@ -139,7 +137,6 @@ class CheckinListViewSet(viewsets.ModelViewSet):
) )
return qs return qs
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -154,7 +151,6 @@ class CheckinListViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -458,8 +454,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, 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, 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, use_order_locale=False):
exchange_medium_type=None, exchange_medium_identifier=None):
if not checkinlists: if not checkinlists:
raise ValidationError('No check-in list passed.') raise ValidationError('No check-in list passed.')
@@ -468,7 +463,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
device = auth if isinstance(auth, Device) else None device = auth if isinstance(auth, Device) else None
gate = gate or (auth.gate if isinstance(auth, Device) else None) gate = gate or (auth.gate if isinstance(auth, Device) else None)
medium = None
context = { context = {
'request': request, 'request': request,
@@ -497,7 +491,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
) )
raw_barcode_for_checkin = None raw_barcode_for_checkin = None
from_revoked_secret = False from_revoked_secret = False
reusable_medium_used = None
if simulate: if simulate:
common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True
@@ -528,12 +521,11 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
# with respecting the force option), or it's a reusable medium (-> proceed with that) # with respecting the force option), or it's a reusable medium (-> proceed with that)
if not op_candidates: if not op_candidates:
try: try:
medium = ReusableMedium.objects.active().filter( media = ReusableMedium.objects.select_related('linked_orderposition').active().get(
Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk')))
).get(
organizer_id=checkinlists[0].event.organizer_id, organizer_id=checkinlists[0].event.organizer_id,
type=source_type, type=source_type,
identifier=raw_barcode, identifier=raw_barcode,
linked_orderposition__isnull=False,
) )
raw_barcode_for_checkin = raw_barcode raw_barcode_for_checkin = raw_barcode
except ReusableMedium.DoesNotExist: except ReusableMedium.DoesNotExist:
@@ -636,9 +628,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data, 'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
}, status=400) }, status=400)
else: else:
linked_ops = medium.linked_orderpositions.all().select_related("order").prefetch_related("addons") if media.linked_orderposition.order.event_id not in list_by_event:
linked_event_ids = {op.order.event_id for op in linked_ops}
if not any(event_id in list_by_event for event_id in linked_event_ids):
# Medium exists but connected ticket is for the wrong event # Medium exists but connected ticket is for the wrong event
if not simulate: if not simulate:
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={ checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
@@ -664,91 +654,28 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'checkin_texts': [], 'checkin_texts': [],
'list': MiniCheckinListSerializer(checkinlists[0]).data, 'list': MiniCheckinListSerializer(checkinlists[0]).data,
}, status=404) }, status=404)
op_candidates = [] op_candidates = [media.linked_orderposition]
for op in linked_ops: if list_by_event[media.linked_orderposition.order.event_id].addon_match:
if op.order.event_id in list_by_event: op_candidates += list(media.linked_orderposition.addons.all())
reusable_medium_used = medium
op_candidates.append(op)
if list_by_event[op.order.event_id].addon_match:
op_candidates += list(op.addons.all())
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary # 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
# key on the same list, we're probably dealing with multiple linked_orderpositions or the ``addon_match`` case # key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
# here and need to figure out which op has the right product. This basically is a valid-for-checkin-test on every op. # which add-on has the right product.
if len(op_candidates) > 1: if len(op_candidates) > 1:
op_candidates_matching_product = [
op for op in op_candidates
if (
(list_by_event[op.order.event_id].addon_match or op.secret == raw_barcode or legacy_url_support) and
(list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()})
)
]
if not reusable_medium_used: if len(op_candidates_matching_product) == 0:
# 3a. First, we clean up that we made an imprecise query above. If a scan is made for multiple check-in lists, # None of the found add-ons has the correct product, too bad! We could just error out here, but
# we have queried ``addon_to__secret=raw_barcode``, even if some of the lists in question do not allow addon
# matching. So we accept all candidates that match one of these cases:
# - Exactly the ticket secret we scanned (because that's always a possible result)
# - Exactly the ticket pk we scanned (on legacy endpoints)
# - An add-on on a list that allows add-on matching
# This is not necessary when a reusable media was used, since in that case we already obeyed list.addon_match
# correctly above.
op_candidates_filtered = [
op for op in op_candidates
if (
op.secret == raw_barcode or
list_by_event[op.order.event_id].addon_match or
(str(op.pk) == raw_barcode and legacy_url_support and not untrusted_input)
)
]
else:
op_candidates_filtered = op_candidates
if len(op_candidates_filtered) > 1:
# 3b. If we still have multiple candidates, we filter by product based on the check-in list configuration.
# This is relevant for the addon_match scenario where the scanned ticket has multiple add-ons, but only
# one is contained in the check-in list used to scan. It makes sense to filter this first, since it is a
# "static" check, i.e. scanning the same QR code on the same check-in list will always do the same, no matter
# when I scan it, and it is "intentional" filtering in the sense that the admin configured this behaviour
# into the check-in list.
op_candidates_filtered = [
op for op in op_candidates_filtered
if list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()}
]
if len(op_candidates_filtered) > 1:
# 3c. If we still have multiple candidates, we filter by validity date. This was introduced for the case where
# a reusable media refers to two tickets, one currently valid and one expired or in the future. Howeer,
# it could in theory also happen with two add-ons being on the same check-in list but without overlapping
# validity. It makes sense to filter this "after" the previous checks since it is not "intentional" filtering
# configured by the admin but "accidental" filtering that depends on the time of execution.
op_candidates_filtered = [
op for op in op_candidates_filtered
if (
(not op.valid_from or op.valid_from <= datetime) and
(not op.valid_until or op.valid_until > datetime)
)
]
if len(op_candidates_filtered) == 0:
# None of the ops is valid today or has the correct product, too bad! We could just error out here, but
# instead we just continue with *any* product and have it rejected by the check in perform_checkin. # instead we just continue with *any* product and have it rejected by the check in perform_checkin.
# To improve the error message, we select the op that will "work next" or - if none matches - "worked last". # This has the advantage of a better error message.
op_candidate = None op_candidates = [op_candidates[0]]
for op in op_candidates: elif len(op_candidates_matching_product) > 1:
if (
op.valid_from and op.valid_from > datetime and
(not op_candidate or op.valid_from < op_candidate.valid_from)
):
op_candidate = op
if not op_candidate:
# no candidate in the future, get closest in the past
for op in op_candidates:
if (
op.valid_until and op.valid_until < datetime and
(not op_candidate or op.valid_until > op_candidate.valid_until)
):
op_candidate = op
if not op_candidate:
op_candidate = op_candidates[0]
op_candidates = [op_candidate]
elif len(op_candidates_filtered) > 1:
# It's still ambiguous, we'll error out. # It's still ambiguous, we'll error out.
# We choose the first match (regardless of product) for the logging since it's most likely to be the # We choose the first match (regardless of product) for the logging since it's most likely to be the
# base product according to our order_by above. # base product according to our order_by above.
@@ -782,7 +709,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400) }, status=400)
else: else:
op_candidates = op_candidates_filtered op_candidates = op_candidates_matching_product
op = op_candidates[0] op = op_candidates[0]
common_checkin_args['list'] = list_by_event[op.order.event_id] common_checkin_args['list'] = list_by_event[op.order.event_id]
@@ -794,10 +721,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
if str(q.pk) in answers_data: if str(q.pk) in answers_data:
try: try:
if q.type == Question.TYPE_FILE: if q.type == Question.TYPE_FILE:
if answers_data[str(q.pk)]: given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
else:
given_answers[q] = None
else: else:
given_answers[q] = q.clean_answer(answers_data[str(q.pk)]) given_answers[q] = q.clean_answer(answers_data[str(q.pk)])
except (ValidationError, BaseValidationError): except (ValidationError, BaseValidationError):
@@ -810,14 +734,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
locale = op.order.event.settings.locale locale = op.order.event.settings.locale
with language(locale): with language(locale):
try: try:
if exchange_medium_identifier and medium: perform_checkin(
# Cannot scan a medium and then request to exchange it
raise CheckInError(
gettext('You cannot exchange a medium for a medium.'),
'error'
)
checkin_args = dict(
op=op, op=op,
clist=list_by_event[op.order.event_id], clist=list_by_event[op.order.event_id],
given_answers=given_answers, given_answers=given_answers,
@@ -835,30 +752,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
from_revoked_secret=from_revoked_secret, from_revoked_secret=from_revoked_secret,
simulate=simulate, simulate=simulate,
gate=gate, gate=gate,
reusable_medium=medium,
) )
if exchange_medium_identifier: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate
if simulate:
raise CheckInError(
gettext('You cannot simulate a medium exchange.'),
'error'
)
with transaction.atomic():
# Do exchange and check-in atomically, i.e. both succeed or both fail
medium = perform_media_exchange(
organizer=request.organizer,
media_type=exchange_medium_type,
identifier=exchange_medium_identifier,
link_orderposition=op,
user=user,
auth=auth,
)
source_type = medium.media_type.identifier
checkin_args['reusable_medium'] = medium
perform_checkin(**checkin_args)
else:
perform_checkin(**checkin_args)
except RequiredQuestionsError as e: except RequiredQuestionsError as e:
return Response({ return Response({
'status': 'incomplete', 'status': 'incomplete',
@@ -870,18 +764,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
], ],
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400) }, status=400)
except RequiredMediaExchangeError as e:
return Response({
'status': 'exchange',
'require_attention': op.require_checkin_attention,
'checkin_texts': op.checkin_texts,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'media_policy': e.media_policy,
'media_type': e.media_type,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
'reason': e.code,
'reason_explanation': e.msg,
}, status=400)
except CheckInError as e: except CheckInError as e:
if not simulate: if not simulate:
op.order.log_action('pretix.event.checkin.denied', data={ op.order.log_action('pretix.event.checkin.denied', data={
@@ -1069,9 +951,6 @@ class CheckinRPCRedeemView(views.APIView):
canceled_supported=True, canceled_supported=True,
request=self.request, # this is not clean, but we need it in the serializers for URL generation request=self.request, # this is not clean, but we need it in the serializers for URL generation
legacy_url_support=False, legacy_url_support=False,
exchange_medium_type=s.validated_data.get('exchange_medium_type'),
exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'),
simulate=s.validated_data.get('simulate'),
) )
-4
View File
@@ -32,7 +32,6 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
@@ -65,7 +64,6 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
'limit_sales_channels', 'limit_sales_channels',
) )
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -80,7 +78,6 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -90,7 +87,6 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied('You cannot delete this discount because it already has ' raise PermissionDenied('You cannot delete this discount because it already has '
+1 -13
View File
@@ -45,7 +45,6 @@ from rest_framework.exceptions import (
NotFound, PermissionDenied, ValidationError, NotFound, PermissionDenied, ValidationError,
) )
from rest_framework.generics import get_object_or_404 from rest_framework.generics import get_object_or_404
from rest_framework.mixins import UpdateModelMixin
from rest_framework.response import Response from rest_framework.response import Response
from pretix.api.auth.permission import EventCRUDPermission from pretix.api.auth.permission import EventCRUDPermission
@@ -257,7 +256,6 @@ class EventViewSet(viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
copy_from = None copy_from = None
if 'clone_from' in self.request.GET: if 'clone_from' in self.request.GET:
@@ -321,7 +319,6 @@ class EventViewSet(viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied('The event can not be deleted as it already contains orders. Please set \'live\'' raise PermissionDenied('The event can not be deleted as it already contains orders. Please set \'live\''
@@ -357,7 +354,6 @@ class CloneEventViewSet(viewsets.ModelViewSet):
ctx['organizer'] = self.request.organizer ctx['organizer'] = self.request.organizer
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
# Weird edge case: Requires settings permission on the event (to read) but also on the organizer (two write) # Weird edge case: Requires settings permission on the event (to read) but also on the organizer (two write)
perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken)) perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken))
@@ -516,7 +512,6 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
resp['X-Page-Generated'] = date resp['X-Page-Generated'] = date
return resp return resp
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data original_data = self.get_serializer(instance=serializer.instance).data
super().perform_update(serializer) super().perform_update(serializer)
@@ -533,7 +528,6 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -543,7 +537,6 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied('The sub-event can not be deleted as it has already been used in orders. Please set' raise PermissionDenied('The sub-event can not be deleted as it has already been used in orders. Please set'
@@ -572,7 +565,6 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
return self.request.event.tax_rules.all() return self.request.event.tax_rules.all()
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
super().perform_update(serializer) super().perform_update(serializer)
serializer.instance.log_action( serializer.instance.log_action(
@@ -582,7 +574,6 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -592,7 +583,6 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied('This tax rule can not be deleted as it is currently in use.') raise PermissionDenied('This tax rule can not be deleted as it is currently in use.')
@@ -721,7 +711,7 @@ class SeatFilter(FilterSet):
fields = ('zone_name', 'row_name', 'row_label', 'seat_number', 'seat_label', 'seat_guid', 'blocked',) fields = ('zone_name', 'row_name', 'row_label', 'seat_number', 'seat_label', 'seat_guid', 'blocked',)
class SeatViewSet(ConditionalListView, UpdateModelMixin, viewsets.ReadOnlyModelViewSet): class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = SeatSerializer serializer_class = SeatSerializer
queryset = Seat.objects.none() queryset = Seat.objects.none()
write_permission = 'event.settings.general:write' write_permission = 'event.settings.general:write'
@@ -766,7 +756,6 @@ class SeatViewSet(ConditionalListView, UpdateModelMixin, viewsets.ReadOnlyModelV
} }
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
super().perform_update(serializer) super().perform_update(serializer)
serializer.instance.event.log_action( serializer.instance.event.log_action(
@@ -776,7 +765,6 @@ class SeatViewSet(ConditionalListView, UpdateModelMixin, viewsets.ReadOnlyModelV
data={"seats": [serializer.instance.pk]}, data={"seats": [serializer.instance.pk]},
) )
@transaction.atomic()
def bulk_change_blocked(self, blocked): def bulk_change_blocked(self, blocked):
s = SeatBulkBlockInputSerializer( s = SeatBulkBlockInputSerializer(
data=self.request.data, data=self.request.data,
+5 -19
View File
@@ -23,7 +23,6 @@ from datetime import timedelta
from celery.result import AsyncResult from celery.result import AsyncResult
from django.conf import settings from django.conf import settings
from django.db import transaction
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -46,8 +45,7 @@ from pretix.base.models import (
) )
from pretix.base.models.organizer import TeamQuerySet from pretix.base.models.organizer import TeamQuerySet
from pretix.base.services.export import ( from pretix.base.services.export import (
ExportError, export, init_event_exporters, init_organizer_exporters, export, init_event_exporters, init_organizer_exporters, multiexport,
multiexport,
) )
from pretix.helpers.http import ChunkBasedFileResponse from pretix.helpers.http import ChunkBasedFileResponse
@@ -151,11 +149,8 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
)) ))
exporters = [] exporters = []
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)): for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
try: ex._serializer = JobRunSerializer(exporter=ex)
ex._serializer = JobRunSerializer(exporter=ex) exporters.append(ex)
exporters.append(ex)
except ExportError:
pass
return exporters return exporters
def do_export(self, cf, instance, data): def do_export(self, cf, instance, data):
@@ -185,11 +180,8 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
)) ))
exporters = [] exporters = []
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)): for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
try: ex._serializer = JobRunSerializer(exporter=ex)
ex._serializer = JobRunSerializer(exporter=ex) exporters.append(ex)
exporters.append(ex)
except ExportError:
pass
return exporters return exporters
def do_export(self, cf, instance, data): def do_export(self, cf, instance, data):
@@ -228,7 +220,6 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
qs = self.request.event.scheduled_exports qs = self.request.event.scheduled_exports
return qs.select_related("owner") return qs.select_related("owner")
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
raise PermissionDenied('Creation of exports requires user-specific API access.') raise PermissionDenied('Creation of exports requires user-specific API access.')
@@ -259,7 +250,6 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
)) ))
return {e.identifier: e for e in exporters} return {e.identifier: e for e in exporters}
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner: if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner:
# This is to prevent a possible privilege escalation where user A creates a scheduled export and # This is to prevent a possible privilege escalation where user A creates a scheduled export and
@@ -285,7 +275,6 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
self.request.event.log_action( self.request.event.log_action(
'pretix.event.export.schedule.deleted', 'pretix.event.export.schedule.deleted',
@@ -313,7 +302,6 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
qs = self.request.organizer.scheduled_exports qs = self.request.organizer.scheduled_exports
return qs.select_related("owner") return qs.select_related("owner")
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
raise PermissionDenied('Creation of exports requires user-specific API access.') raise PermissionDenied('Creation of exports requires user-specific API access.')
@@ -344,7 +332,6 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
)) ))
return {e.identifier: e for e in exporters} return {e.identifier: e for e in exporters}
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner: if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner:
# This is to prevent a possible privilege escalation where user A creates a scheduled export and # This is to prevent a possible privilege escalation where user A creates a scheduled export and
@@ -395,7 +382,6 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
self.request.organizer.log_action( self.request.organizer.log_action(
'pretix.organizer.export.schedule.deleted', 'pretix.organizer.export.schedule.deleted',
-28
View File
@@ -33,7 +33,6 @@
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
import django_filters import django_filters
from django.db import transaction
from django.db.models import Q from django.db.models import Q
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -110,7 +109,6 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times' 'limit_sales_channels', 'variations__limit_sales_channels', 'program_times'
).all() ).all()
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -125,7 +123,6 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data original_data = self.get_serializer(instance=serializer.instance).data
@@ -142,7 +139,6 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied('This item cannot be deleted because it has already been ordered ' raise PermissionDenied('This item cannot be deleted because it has already been ordered '
@@ -187,7 +183,6 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
item = self.item item = self.item
if not item.has_variations: if not item.has_variations:
@@ -202,7 +197,6 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
{'value': serializer.instance.value}) {'value': serializer.instance.value})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.item.log_action( serializer.instance.item.log_action(
@@ -213,7 +207,6 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
{'value': serializer.instance.value}) {'value': serializer.instance.value})
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied('This variation cannot be deleted because it has already been ordered ' raise PermissionDenied('This variation cannot be deleted because it has already been ordered '
@@ -256,7 +249,6 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
ctx['item'] = self.item ctx['item'] = self.item
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
serializer.save(base_item=item) serializer.save(base_item=item)
@@ -267,7 +259,6 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.base_item.log_action( serializer.instance.base_item.log_action(
@@ -277,7 +268,6 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
super().perform_destroy(instance) super().perform_destroy(instance)
instance.base_item.log_action( instance.base_item.log_action(
@@ -313,7 +303,6 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
ctx['item'] = self.item ctx['item'] = self.item
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
serializer.save(item=item) serializer.save(item=item)
@@ -324,7 +313,6 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.item.log_action( serializer.instance.item.log_action(
@@ -334,7 +322,6 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
super().perform_destroy(instance) super().perform_destroy(instance)
instance.item.log_action( instance.item.log_action(
@@ -367,7 +354,6 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
ctx['item'] = self.item ctx['item'] = self.item
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
item = self.item item = self.item
category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category']) category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category'])
@@ -379,7 +365,6 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.base_item.log_action( serializer.instance.base_item.log_action(
@@ -389,7 +374,6 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
super().perform_destroy(instance) super().perform_destroy(instance)
instance.base_item.log_action( instance.base_item.log_action(
@@ -419,7 +403,6 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
return self.request.event.categories.all() return self.request.event.categories.all()
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -434,7 +417,6 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -444,7 +426,6 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
for item in instance.items.all(): for item in instance.items.all():
item.category = None item.category = None
@@ -477,7 +458,6 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
return self.request.event.questions.prefetch_related('options').all() return self.request.event.questions.prefetch_related('options').all()
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -492,7 +472,6 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -502,7 +481,6 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
instance.log_action( instance.log_action(
'pretix.event.question.deleted', 'pretix.event.question.deleted',
@@ -531,7 +509,6 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
ctx['question'] = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event) ctx['question'] = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event) q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
serializer.save(question=q) serializer.save(question=q)
@@ -542,7 +519,6 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.question.log_action( serializer.instance.question.log_action(
@@ -552,7 +528,6 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
instance.question.log_action( instance.question.log_action(
'pretix.event.question.option.deleted', 'pretix.event.question.option.deleted',
@@ -611,7 +586,6 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer = self.get_serializer(page, many=True) serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data) return self.get_paginated_response(serializer.data)
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -634,7 +608,6 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['request'] = self.request ctx['request'] = self.request
return ctx return ctx
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data original_data = self.get_serializer(instance=serializer.instance).data
@@ -690,7 +663,6 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
) )
serializer.instance.rebuild_cache() serializer.instance.rebuild_cache()
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
instance.log_action( instance.log_action(
'pretix.event.quota.deleted', 'pretix.event.quota.deleted',
+11 -36
View File
@@ -53,12 +53,10 @@ with scopes_disabled():
customer = django_filters.CharFilter(field_name='customer__identifier') customer = django_filters.CharFilter(field_name='customer__identifier')
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte') updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte') created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
# backwards-compatible
linked_orderposition = django_filters.NumberFilter(field_name='linked_orderpositions__id')
class Meta: class Meta:
model = ReusableMedium model = ReusableMedium
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderpositions', 'linked_giftcard'] fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard']
class ReusableMediaViewSet(viewsets.ModelViewSet): class ReusableMediaViewSet(viewsets.ModelViewSet):
@@ -77,7 +75,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
).order_by().values('card').annotate(s=Sum('value')).values('s') ).order_by().values('card').annotate(s=Sum('value')).values('s')
return self.request.organizer.reusable_media.prefetch_related( return self.request.organizer.reusable_media.prefetch_related(
Prefetch( Prefetch(
'linked_orderpositions', 'linked_orderposition',
queryset=OrderPosition.objects.select_related( queryset=OrderPosition.objects.select_related(
'order', 'order__event', 'order__event__organizer', 'seat', 'order', 'order__event', 'order__event__organizer', 'seat',
).prefetch_related( ).prefetch_related(
@@ -119,38 +117,14 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
@transaction.atomic() @transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
rm = ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk) ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
prev_linked_ops_pks = list(rm.linked_orderpositions.values_list("pk", flat=True))
inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type) inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type)
linked_ops_pks = inst.linked_orderpositions.values_list("pk", flat=True) inst.log_action(
for op_pk in prev_linked_ops_pks: 'pretix.reusable_medium.changed',
if op_pk not in linked_ops_pks: user=self.request.user,
inst.log_action( auth=self.request.auth,
'pretix.reusable_medium.linked_orderposition.removed', data=self.request.data,
user=self.request.user, )
auth=self.request.auth,
data={
'linked_orderposition': op_pk,
}
)
for op_pk in linked_ops_pks:
if op_pk not in prev_linked_ops_pks:
inst.log_action(
'pretix.reusable_medium.linked_orderposition.added',
user=self.request.user,
auth=self.request.auth,
data={
'linked_orderposition': op_pk,
}
)
data = {k: v for k, v in self.request.data.items() if k not in ('linked_orderposition', 'linked_orderpositions')}
if data:
inst.log_action(
'pretix.reusable_medium.changed',
user=self.request.user,
auth=self.request.auth,
data=data,
)
return inst return inst
def perform_destroy(self, instance): def perform_destroy(self, instance):
@@ -183,6 +157,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
type=s.validated_data["type"], type=s.validated_data["type"],
identifier=s.validated_data["identifier"], identifier=s.validated_data["identifier"],
) )
m.linked_orderposition = None # not relevant for cross-organizer
m.customer = None # not relevant for cross-organizer m.customer = None # not relevant for cross-organizer
s = self.get_serializer(m) s = self.get_serializer(m)
return Response({"result": s.data}) return Response({"result": s.data})
@@ -196,7 +171,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
return Response({"result": None}) return Response({"result": None})
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some performance @scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
def list(self, request, **kwargs): def list(self, request, **kwargs):
date = serializers.DateTimeField().to_representation(now()) date = serializers.DateTimeField().to_representation(now())
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
+3 -5
View File
@@ -194,7 +194,7 @@ with scopes_disabled():
) )
).values('id') ).values('id')
matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderpositions__order_id', flat=True) matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderposition__order_id', flat=True)
mainq = ( mainq = (
code code
@@ -1034,7 +1034,7 @@ with scopes_disabled():
search = django_filters.CharFilter(method='search_qs') search = django_filters.CharFilter(method='search_qs')
def search_qs(self, queryset, name, value): def search_qs(self, queryset, name, value):
matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderpositions', flat=True) matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderposition', flat=True)
return queryset.filter( return queryset.filter(
Q(secret__istartswith=value) Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value) | Q(attendee_name_cached__icontains=value)
@@ -1658,7 +1658,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
count_waitinglist=False, count_waitinglist=False,
force=request.data.get('force', False), force=request.data.get('force', False),
send_mail=send_mail, send_mail=send_mail,
ignore_date=request.data.get('force', False),
) )
except Quota.QuotaExceededException: except Quota.QuotaExceededException:
pass pass
@@ -1694,8 +1693,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
auth=self.request.auth, auth=self.request.auth,
count_waitinglist=False, count_waitinglist=False,
send_mail=send_mail, send_mail=send_mail,
force=force, force=force)
ignore_date=force)
except Quota.QuotaExceededException as e: except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e: except PaymentException as e:
-3
View File
@@ -394,7 +394,6 @@ class TeamViewSet(viewsets.ModelViewSet):
) )
return inst return inst
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
instance.log_action('pretix.team.deleted', user=self.request.user, auth=self.request.auth) instance.log_action('pretix.team.deleted', user=self.request.user, auth=self.request.auth)
instance.delete() instance.delete()
@@ -694,7 +693,6 @@ class MembershipTypeViewSet(viewsets.ModelViewSet):
ctx['organizer'] = self.request.organizer ctx['organizer'] = self.request.organizer
return ctx return ctx
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied("Can only be deleted if unused.") raise PermissionDenied("Can only be deleted if unused.")
@@ -835,7 +833,6 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
) )
return inst return inst
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if not instance.allow_delete(): if not instance.allow_delete():
raise PermissionDenied("Can only be deleted if unused.") raise PermissionDenied("Can only be deleted if unused.")
-4
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import django_filters import django_filters
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
@@ -63,7 +62,6 @@ class WaitingListViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
serializer.instance.log_action( serializer.instance.log_action(
@@ -72,7 +70,6 @@ class WaitingListViewSet(viewsets.ModelViewSet):
auth=self.request.auth, auth=self.request.auth,
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
if serializer.instance.voucher: if serializer.instance.voucher:
raise PermissionDenied('This entry can not be changed as it has already been assigned a voucher.') raise PermissionDenied('This entry can not be changed as it has already been assigned a voucher.')
@@ -83,7 +80,6 @@ class WaitingListViewSet(viewsets.ModelViewSet):
auth=self.request.auth, auth=self.request.auth,
) )
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if instance.voucher: if instance.voucher:
raise PermissionDenied('This entry can not be deleted as it has already been assigned a voucher.') raise PermissionDenied('This entry can not be deleted as it has already been assigned a voucher.')
-4
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import django_filters import django_filters
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets from rest_framework import viewsets
@@ -49,7 +48,6 @@ class WebHookViewSet(viewsets.ModelViewSet):
ctx['organizer'] = self.request.organizer ctx['organizer'] = self.request.organizer
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer) inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action( self.request.organizer.log_action(
@@ -59,7 +57,6 @@ class WebHookViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': inst.pk}) data=merge_dicts(self.request.data, {'id': inst.pk})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
inst = serializer.save(organizer=self.request.organizer) inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action( self.request.organizer.log_action(
@@ -70,7 +67,6 @@ class WebHookViewSet(viewsets.ModelViewSet):
) )
return inst return inst
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
self.request.organizer.log_action( self.request.organizer.log_action(
'pretix.webhook.changed', 'pretix.webhook.changed',
+9 -12
View File
@@ -23,7 +23,6 @@ import sys
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext from django.utils.translation import gettext
@@ -36,23 +35,21 @@ def get_powered_by(request, safelink=True):
d = gs.settings.license_check_input d = gs.settings.license_check_input
if d.get('poweredby_name'): if d.get('poweredby_name'):
if d.get('poweredby_url'): if d.get('poweredby_url'):
msg = format_html( msg = gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>').format(
gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>'),
name=d['poweredby_name'], name=d['poweredby_name'],
a_name_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format( a_name_attr='href="{}" target="_blank" rel="noopener"'.format(
escape(sl(d['poweredby_url'])) if safelink else escape(d['poweredby_url']), sl(d['poweredby_url']) if safelink else d['poweredby_url'],
)), ),
a_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format( a_attr='href="{}" target="_blank" rel="noopener"'.format(
sl('https://pretix.eu') if safelink else 'https://pretix.eu', sl('https://pretix.eu') if safelink else 'https://pretix.eu',
)) )
) )
else: else:
msg = format_html( msg = gettext('<a {a_attr}>powered by {name} based on pretix</a>').format(
gettext('<a {a_attr}>powered by {name} based on pretix</a>'),
name=d['poweredby_name'], name=d['poweredby_name'],
a_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format( a_attr='href="{}" target="_blank" rel="noopener"'.format(
sl('https://pretix.eu') if safelink else 'https://pretix.eu', sl('https://pretix.eu') if safelink else 'https://pretix.eu',
)) )
) )
else: else:
msg = gettext('<a %(a_attr)s>ticketing powered by pretix</a>') % { msg = gettext('<a %(a_attr)s>ticketing powered by pretix</a>') % {
+2 -2
View File
@@ -36,7 +36,7 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from requests import RequestException from requests import RequestException
from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.multidomain.urlreverse import build_absolute_uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -313,7 +313,7 @@ def _get_or_create_server_keypair(organizer):
def generate_id_token(customer, client, auth_time, nonce, scope, expires: datetime, scope_claims=False, with_code=None, with_access_token=None): def generate_id_token(customer, client, auth_time, nonce, scope, expires: datetime, scope_claims=False, with_code=None, with_access_token=None):
payload = { payload = {
'iss': eventreverse_absolute(client.organizer, 'presale:organizer.index').rstrip('/'), 'iss': build_absolute_uri(client.organizer, 'presale:organizer.index').rstrip('/'),
'aud': client.client_id, 'aud': client.client_id,
'exp': int(expires.timestamp()), 'exp': int(expires.timestamp()),
'iat': int(time.time()), 'iat': int(time.time()),
+3 -3
View File
@@ -28,7 +28,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import Checkin, InvoiceAddress, Order, Question from pretix.base.models import Checkin, InvoiceAddress, Order, Question
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.multidomain.urlreverse import build_absolute_uri
def get_answer(op, question_identifier=None): def get_answer(op, question_identifier=None):
@@ -545,7 +545,7 @@ def get_data_fields(event, for_model=None):
_("Order link"), _("Order link"),
Question.TYPE_STRING, Question.TYPE_STRING,
None, None,
lambda order: eventreverse_absolute( lambda order: build_absolute_uri(
event, event,
'presale:event.order', kwargs={ 'presale:event.order', kwargs={
'order': order.code, 'order': order.code,
@@ -560,7 +560,7 @@ def get_data_fields(event, for_model=None):
_("Ticket link"), _("Ticket link"),
Question.TYPE_STRING, Question.TYPE_STRING,
None, None,
lambda op: eventreverse_absolute( lambda op: build_absolute_uri(
event, event,
'presale:event.order.position', kwargs={ 'presale:event.order.position', kwargs={
'order': op.order.code, 'order': op.order.code,
+8 -4
View File
@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import ipaddress
import logging import logging
import smtplib import smtplib
import socket import socket
@@ -42,7 +43,6 @@ from pretix.base.templatetags.rich_text import (
markdown_compile_email, truelink_callback, markdown_compile_email, truelink_callback,
) )
from pretix.helpers.format import FormattedString, SafeFormatter, format_map from pretix.helpers.format import FormattedString, SafeFormatter, format_map
from pretix.helpers.ssrf import should_block_access
from pretix.base.services.placeholders import ( # noqa from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext get_available_placeholders, PlaceholderContext
@@ -252,9 +252,13 @@ def create_connection(address, timeout=socket.getdefaulttimeout(),
af, socktype, proto, canonname, sa = res af, socktype, proto, canonname, sa = res
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False): if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
is_private, msg = should_block_access(sa) ip_addr = ipaddress.ip_address(sa[0])
if is_private: if ip_addr.is_multicast:
raise socket.error(msg) raise socket.error(f"Request to multicast address {sa[0]} blocked")
if ip_addr.is_loopback or ip_addr.is_link_local:
raise socket.error(f"Request to local address {sa[0]} blocked")
if ip_addr.is_private:
raise socket.error(f"Request to private address {sa[0]} blocked")
sock = None sock = None
try: try:
+3 -57
View File
@@ -40,12 +40,11 @@ from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from ..exporter import MultiSheetListExporter, OrganizerLevelExportMixin from ..exporter import ListExporter, OrganizerLevelExportMixin
from ..models import Membership
from ..signals import register_multievent_data_exporters from ..signals import register_multievent_data_exporters
class CustomerListExporter(OrganizerLevelExportMixin, MultiSheetListExporter): class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'customerlist' identifier = 'customerlist'
verbose_name = gettext_lazy('Customer accounts') verbose_name = gettext_lazy('Customer accounts')
category = pgettext_lazy('export_category', 'Customer accounts') category = pgettext_lazy('export_category', 'Customer accounts')
@@ -55,20 +54,13 @@ class CustomerListExporter(OrganizerLevelExportMixin, MultiSheetListExporter):
def get_required_organizer_permission(cls) -> str: def get_required_organizer_permission(cls) -> str:
return 'organizer.customers:write' return 'organizer.customers:write'
@property
def sheets(self):
return (
('customers', _('Customers')),
('memberships', _('Memberships')),
)
@property @property
def additional_form_fields(self): def additional_form_fields(self):
return OrderedDict( return OrderedDict(
[] []
) )
def iterate_customers(self, form_data): def iterate_list(self, form_data):
qs = self.organizer.customers.prefetch_related('provider') qs = self.organizer.customers.prefetch_related('provider')
headers = [ headers = [
@@ -117,52 +109,6 @@ class CustomerListExporter(OrganizerLevelExportMixin, MultiSheetListExporter):
] ]
yield row yield row
def iterate_memberships(self, form_data):
qs = Membership.objects.filter(
customer__organizer=self.organizer
).prefetch_related('membership_type').select_related('customer', 'granted_in', 'granted_in__order')
headers = [
_('Customer ID'),
_('External identifier'),
_('Email'),
_('Test mode'),
_('Canceled'),
_('Membership type'),
_('Purchase ticket'),
_('Start date'),
_('Start time'),
_('End date'),
_('End time'),
_('Name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
headers.append(_('Name') + ': ' + str(label))
yield headers
tz = get_current_timezone()
for obj in qs:
row = [
obj.customer.identifier,
obj.customer.external_identifier,
obj.customer.email or '',
_('Yes') if obj.testmode else _('No'),
_('Yes') if obj.canceled else _('No'),
str(obj.membership_type.name),
f'{obj.granted_in.order.code}-{obj.granted_in.positionid}' if obj.granted_in else None,
obj.date_start.astimezone(tz).strftime('%Y-%m-%d'),
obj.date_start.astimezone(tz).strftime('%H:%M'),
obj.date_end.astimezone(tz).strftime('%Y-%m-%d'),
obj.date_end.astimezone(tz).strftime('%H:%M'),
obj.attendee_name or '',
]
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(obj.attendee_name_parts.get(k, ''))
yield row
def get_filename(self): def get_filename(self):
return '{}_customers'.format(self.organizer.slug) return '{}_customers'.format(self.organizer.slug)
+3 -3
View File
@@ -68,7 +68,7 @@ from ...control.forms.filter import get_all_payment_providers
from ...helpers import GroupConcat from ...helpers import GroupConcat
from ...helpers.iter import chunked_iterable from ...helpers.iter import chunked_iterable
from ...helpers.safe_openpyxl import remove_invalid_excel_chars from ...helpers.safe_openpyxl import remove_invalid_excel_chars
from ...multidomain.urlreverse import eventreverse_absolute from ...multidomain.urlreverse import build_absolute_uri
from ..exporter import ( from ..exporter import (
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin, ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
) )
@@ -429,7 +429,7 @@ class OrderListExporter(MultiSheetListExporter):
])) ]))
row.append( row.append(
eventreverse_absolute(order.event, 'presale:event.order', kwargs={ build_absolute_uri(order.event, 'presale:event.order', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret,
}) })
@@ -855,7 +855,7 @@ class OrderListExporter(MultiSheetListExporter):
])) ]))
row.append( row.append(
eventreverse_absolute(order.event, 'presale:event.order.position', kwargs={ build_absolute_uri(order.event, 'presale:event.order.position', kwargs={
'order': order.code, 'order': order.code,
'secret': op.web_secret, 'secret': op.web_secret,
'position': op.positionid 'position': op.positionid
+6 -14
View File
@@ -20,13 +20,12 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from django.db.models import Prefetch
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy
from ..exporter import ListExporter, OrganizerLevelExportMixin from ..exporter import ListExporter, OrganizerLevelExportMixin
from ..models import OrderPosition, ReusableMedium from ..models import ReusableMedium
from ..signals import register_multievent_data_exporters from ..signals import register_multievent_data_exporters
@@ -45,9 +44,7 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
media = ReusableMedium.objects.filter( media = ReusableMedium.objects.filter(
organizer=self.organizer, organizer=self.organizer,
).select_related( ).select_related(
'customer', 'linked_giftcard', 'customer', 'linked_orderposition', 'linked_giftcard',
).prefetch_related(
Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order"))
).order_by('created') ).order_by('created')
headers = [ headers = [
@@ -64,23 +61,18 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
yield headers yield headers
yield self.ProgressSetTotal(total=media.count()) yield self.ProgressSetTotal(total=media.count())
can_read_giftcards = self.permission_holder.has_organizer_permission(self.organizer, 'organizer.giftcards:read')
for medium in media.iterator(chunk_size=1000): for medium in media.iterator(chunk_size=1000):
giftcard_secret = medium.linked_giftcard.secret if medium.linked_giftcard_id else '' row = [
if giftcard_secret and not can_read_giftcards:
giftcard_secret = giftcard_secret[:3] + ""
yield [
medium.type, medium.type,
medium.identifier, medium.identifier,
_('Yes') if medium.active else _('No'), _('Yes') if medium.active else _('No'),
date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '', date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '',
medium.customer.identifier if medium.customer_id else '', medium.customer.identifier if medium.customer_id else '',
', '.join([f"{op.order.code}-{op.positionid}" for op in medium.linked_orderpositions.all()]), f"{medium.linked_orderposition.order.code}-{medium.linked_orderposition.positionid}" if medium.linked_orderposition_id else '',
giftcard_secret, medium.linked_giftcard.secret if medium.linked_giftcard_id else '',
medium.notes, medium.notes,
] ]
yield row
def get_filename(self): def get_filename(self):
return f'{self.organizer.slug}_media' return f'{self.organizer.slug}_media'
+32 -9
View File
@@ -33,6 +33,8 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
import hashlib
import ipaddress
import logging import logging
from django import forms from django import forms
@@ -40,12 +42,13 @@ from django.conf import settings
from django.contrib.auth.password_validation import ( from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password, password_validators_help_texts, validate_password,
) )
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.metrics import pretix_failed_logins from pretix.base.metrics import pretix_failed_logins
from pretix.base.models import User from pretix.base.models import User
from pretix.helpers.dicts import move_to_end from pretix.helpers.dicts import move_to_end
from pretix.helpers.ratelimit import rate_limit from pretix.helpers.http import get_client_ip
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -82,20 +85,40 @@ class LoginForm(forms.Form):
else: else:
move_to_end(self.fields, 'keep_logged_in') move_to_end(self.fields, 'keep_logged_in')
@cached_property
def ratelimit_key(self):
if not settings.HAS_REDIS:
return None
client_ip = get_client_ip(self.request)
if not client_ip:
return None
try:
client_ip = ipaddress.ip_address(client_ip)
except ValueError:
# Web server not set up correctly
return None
if client_ip.is_private:
# This is the private IP of the server, web server not set up correctly
return None
return 'pretix_login_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
def clean(self): def clean(self):
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required): if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
rate_limit_kwargs = dict(include_ip_from_request=self.request, max_num=10, expire_time=300) if self.ratelimit_key:
if rate_limit("login", **rate_limit_kwargs, increase=False): from django_redis import get_redis_connection
# Check rate limit without counting up, we increase below only on failed logins rc = get_redis_connection("redis")
pretix_failed_logins.inc(1, reason="ratelimit") cnt = rc.get(self.ratelimit_key)
logger.info("Backend login rejected due to rate limit.") if cnt and int(cnt) > 10:
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit') pretix_failed_logins.inc(1, reason="ratelimit")
logger.info("Backend login rejected due to rate limit.")
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data) self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
if self.user_cache is None: if self.user_cache is None:
if self.ratelimit_key:
rc.incr(self.ratelimit_key)
rc.expire(self.ratelimit_key, 300)
logger.info("Backend login invalid.") logger.info("Backend login invalid.")
pretix_failed_logins.inc(1, reason="invalid") pretix_failed_logins.inc(1, reason="invalid")
# Count towards rate limit (result is ignored, we are checking above)
rate_limit("login", **rate_limit_kwargs)
raise forms.ValidationError( raise forms.ValidationError(
self.error_messages['invalid_login'], self.error_messages['invalid_login'],
code='invalid_login' code='invalid_login'
+11 -24
View File
@@ -53,7 +53,6 @@ from django.db.models import QuerySet
from django.forms import Select, widgets from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.functional import lazy
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.text import format_lazy from django.utils.text import format_lazy
@@ -325,21 +324,16 @@ class WrappedPhonePrefixSelect(Select):
initial = None initial = None
def __init__(self, initial=None): def __init__(self, initial=None):
def _get_choices(): choices = [("", "---------")]
choices = [("", "---------")]
if initial:
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
continue
if initial in values:
self.initial = "+%d" % prefix
break
choices += get_phone_prefixes_sorted_and_localized()
return choices
choices = lazy(_get_choices, list)()
if initial:
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
continue
if initial in values:
self.initial = "+%d" % prefix
break
choices += get_phone_prefixes_sorted_and_localized()
super().__init__(choices=choices, attrs={ super().__init__(choices=choices, attrs={
'aria-label': pgettext_lazy('phonenumber', 'International area code'), 'aria-label': pgettext_lazy('phonenumber', 'International area code'),
'autocomplete': 'tel-country-code', 'autocomplete': 'tel-country-code',
@@ -959,7 +953,7 @@ class BaseQuestionsForm(forms.Form):
label=label, required=required, label=label, required=required,
help_text=help_text, help_text=help_text,
initial=_initial, initial=_initial,
widget=TimePickerWidget(without_seconds=True), widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
) )
elif q.type == Question.TYPE_DATETIME: elif q.type == Question.TYPE_DATETIME:
if not help_text: if not help_text:
@@ -1116,13 +1110,6 @@ class BaseQuestionsForm(forms.Form):
if q.dependency_question_id and not question_is_visible(q.dependency_question_id, q.dependency_values) and answer is not None: if q.dependency_question_id and not question_is_visible(q.dependency_question_id, q.dependency_values) and answer is not None:
d['question_%d' % q.pk] = None d['question_%d' % q.pk] = None
# Strip False answers to required yes/no questions even if all_optional is set, as our data model assumes that
# required yes/no questions can only be answered with yes
for q in question_cache.values():
if q.required and q.type == Question.TYPE_BOOLEAN:
if 'question_%d' % q.pk in d and d['question_%d' % q.pk] is False:
d['question_%d' % q.pk] = None
return d return d
@@ -1411,7 +1398,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
elif self.validate_vat_id and vat_id_applicable: elif self.validate_vat_id and vat_id_applicable:
try: try:
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country'))) normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
self.instance.vat_id_validated = bool(normalized_id) self.instance.vat_id_validated = True
self.instance.vat_id = data['vat_id'] = normalized_id self.instance.vat_id = data['vat_id'] = normalized_id
except VATIDFinalError as e: except VATIDFinalError as e:
if self.all_optional: if self.all_optional:
+11 -22
View File
@@ -33,6 +33,7 @@
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
from django import forms from django import forms
from django.conf import settings
from django.contrib.auth.hashers import check_password from django.contrib.auth.hashers import check_password
from django.contrib.auth.password_validation import ( from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password, password_validators_help_texts, validate_password,
@@ -45,7 +46,6 @@ from pytz import common_timezones
from pretix.base.models import User from pretix.base.models import User
from pretix.control.forms import SingleLanguageWidget from pretix.control.forms import SingleLanguageWidget
from pretix.helpers.format import format_map from pretix.helpers.format import format_map
from pretix.helpers.ratelimit import rate_limit
class UserSettingsForm(forms.ModelForm): class UserSettingsForm(forms.ModelForm):
@@ -128,11 +128,16 @@ class UserPasswordChangeForm(forms.Form):
def clean_old_pw(self): def clean_old_pw(self):
old_pw = self.cleaned_data.get('old_pw') old_pw = self.cleaned_data.get('old_pw')
if rate_limit("pwchange", self.user.pk, max_num=10, expire_time=300): if settings.HAS_REDIS:
raise forms.ValidationError( from django_redis import get_redis_connection
self.error_messages['rate_limit'], rc = get_redis_connection("redis")
code='rate_limit', cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
) rc.expire('pretix_pwchange_%s' % self.user.pk, 300)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if not check_password(old_pw, self.user.password): if not check_password(old_pw, self.user.password):
raise forms.ValidationError( raise forms.ValidationError(
@@ -170,35 +175,19 @@ class UserEmailChangeForm(forms.Form):
error_messages = { error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. " 'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."), "Please choose a different one."),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
} }
old_email = forms.EmailField(label=_('Old email address'), disabled=True) old_email = forms.EmailField(label=_('Old email address'), disabled=True)
new_email = forms.EmailField(label=_('New email address')) new_email = forms.EmailField(label=_('New email address'))
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user') self.user = kwargs.pop('user')
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def clean_new_email(self): def clean_new_email(self):
email = self.cleaned_data['new_email'] email = self.cleaned_data['new_email']
if rate_limit("emailchange_attempt", include_ip_from_request=self.request, max_num=5, expire_time=300):
# Rate limit lookup for conflicting email addresses to make enumeration harder
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists(): if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists():
raise forms.ValidationError( raise forms.ValidationError(
self.error_messages['duplicate_identifier'], self.error_messages['duplicate_identifier'],
code='duplicate_identifier', code='duplicate_identifier',
) )
if rate_limit("emailchange", self.user.pk, max_num=2, expire_time=300):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
return email return email
+5 -50
View File
@@ -43,10 +43,6 @@ from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.helpers.format import PlainHtmlAlternativeString from pretix.helpers.format import PlainHtmlAlternativeString
from pretix.helpers.i18n import (
get_format_without_seconds, get_javascript_format,
get_javascript_format_without_seconds,
)
def replace_arabic_numbers(inp): def replace_arabic_numbers(inp):
@@ -112,7 +108,7 @@ class DatePickerWidget(forms.DateInput):
class TimePickerWidget(forms.TimeInput): class TimePickerWidget(forms.TimeInput):
def __init__(self, attrs=None, time_format=None, without_seconds=False): def __init__(self, attrs=None, time_format=None):
attrs = attrs or {} attrs = attrs or {}
if 'placeholder' in attrs: if 'placeholder' in attrs:
del attrs['placeholder'] del attrs['placeholder']
@@ -121,27 +117,8 @@ class TimePickerWidget(forms.TimeInput):
time_attrs['class'] += ' timepickerfield' time_attrs['class'] += ' timepickerfield'
time_attrs['autocomplete'] = 'off' time_attrs['autocomplete'] = 'off'
if time_format or without_seconds:
# Explicitly set data-format attributes for the JS layer instead of relying on the body-wide config
def time_format_attr():
if without_seconds:
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
time_attrs['data-format'] = lazy(time_format_attr, str)
def time_format_attr():
if without_seconds:
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
time_attrs['data-format'] = lazy(time_format_attr, str)
def placeholder(): def placeholder():
if without_seconds: tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
tf = time_format or get_format_without_seconds('TIME_INPUT_FORMATS')
else:
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
return now().replace( return now().replace(
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0 year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
).strftime(tf) ).strftime(tf)
@@ -205,7 +182,7 @@ class UploadedFileWidget(forms.ClearableFileInput):
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget): class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
template_name = 'pretixbase/forms/widgets/splitdatetime.html' template_name = 'pretixbase/forms/widgets/splitdatetime.html'
def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None, without_seconds=False): def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None):
attrs = attrs or {} attrs = attrs or {}
if 'placeholder' in attrs: if 'placeholder' in attrs:
del attrs['placeholder'] del attrs['placeholder']
@@ -228,36 +205,14 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
max_date if not isinstance(max_date, datetime) else max_date.astimezone(get_current_timezone()).date() max_date if not isinstance(max_date, datetime) else max_date.astimezone(get_current_timezone()).date()
).isoformat() ).isoformat()
if date_format or time_format or without_seconds:
# Explicitly set data-format attributes for the JS layer instead of relying on the body-wide config
def date_format_attr():
if without_seconds:
return get_javascript_format_without_seconds(date_format or "DATE_INPUT_FORMATS")
return get_javascript_format(date_format or "DATE_INPUT_FORMATS")
date_attrs['data-format'] = lazy(date_format_attr, str)
def time_format_attr():
if without_seconds:
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
time_attrs['data-format'] = lazy(time_format_attr, str)
def date_placeholder(): def date_placeholder():
if without_seconds: df = date_format or get_format('DATE_INPUT_FORMATS')[0]
df = date_format or get_format_without_seconds('DATE_INPUT_FORMATS')
else:
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
return now().replace( return now().replace(
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0 year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
).strftime(df) ).strftime(df)
def time_placeholder(): def time_placeholder():
if without_seconds: tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
tf = time_format or get_format_without_seconds('TIME_INPUT_FORMATS')
else:
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
return now().replace( return now().replace(
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0 year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
).strftime(tf) ).strftime(tf)
+90 -76
View File
@@ -22,7 +22,9 @@
import datetime import datetime
import logging import logging
import math import math
import re
import textwrap import textwrap
import unicodedata
from collections import defaultdict from collections import defaultdict
from decimal import Decimal from decimal import Decimal
from io import BytesIO from io import BytesIO
@@ -56,8 +58,8 @@ from pretix.base.services.currencies import SOURCE_NAMES
from pretix.base.signals import register_invoice_renderers from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import ( from pretix.helpers.reportlab import (
FontFallbackParagraph, PlainTextParagraph, ThumbnailingImageReader, FontFallbackParagraph, ThumbnailingImageReader, register_ttf_font_if_new,
normalize_text, register_ttf_font_if_new, reshaper, reshaper,
) )
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
@@ -257,8 +259,18 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype'])) register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
def _normalize(self, text): def _normalize(self, text):
# alias kept for plugin compatibility # reportlab does not support unicode combination characters
return normalize_text(text) # It's important we do this before we use ArabicReshaper
text = unicodedata.normalize("NFKC", text)
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
# to resolve all ligatures and python-bidi to switch RTL texts.
try:
text = "<br />".join(get_display(reshaper.reshape(l)) for l in re.split("<br ?/>", text))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
return text
def _upper(self, val): def _upper(self, val):
# We uppercase labels, but not in every language # We uppercase labels, but not in every language
@@ -339,15 +351,10 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
return 'invoice.pdf', 'application/pdf', buffer.read() return 'invoice.pdf', 'application/pdf', buffer.read()
def _clean_text(self, text, tags=None): def _clean_text(self, text, tags=None):
# For backwards compatibility with customer content, we need to support tags like <br> and <b> in a few text return self._normalize(bleach.clean(
# fields. Therefore, we can't use PlainTextParagraph for these, but run bleach instead to limit the allowed text,
# tags. tags=set(tags) if tags else set()
return self._normalize( ).strip().replace('<br>', '<br />').replace('\n', '<br />\n'))
bleach.clean(
text,
tags=set(tags) if tags else set()
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')
)
class PaidMarker(Flowable): class PaidMarker(Flowable):
@@ -398,7 +405,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_to_top = 52 * mm invoice_to_top = 52 * mm
def _draw_invoice_to(self, canvas): def _draw_invoice_to(self, canvas):
p = PlainTextParagraph(self.invoice.address_invoice_to, style=self.stylesheet['Normal']) p = FontFallbackParagraph(self._clean_text(self.invoice.address_invoice_to),
style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height) p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
p_size = p.wrap(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) p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
@@ -409,8 +417,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_from_top = 17 * mm invoice_from_top = 17 * mm
def _draw_invoice_from(self, canvas): def _draw_invoice_from(self, canvas):
p = PlainTextParagraph( p = FontFallbackParagraph(
self.invoice.full_invoice_from, self._clean_text(self.invoice.full_invoice_from),
style=self.stylesheet['InvoiceFrom'] style=self.stylesheet['InvoiceFrom']
) )
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height) p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
@@ -540,12 +548,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_event(self, canvas): def _draw_event(self, canvas):
def shorten(txt): def shorten(txt):
txt = str(txt) txt = str(txt)
p = PlainTextParagraph(txt, style=self.stylesheet['Normal']) txt = bleach.clean(txt, tags=set()).strip()
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(self.event_width, self.event_height)
while p_size[1] > 2 * self.stylesheet['Normal'].leading: while p_size[1] > 2 * self.stylesheet['Normal'].leading:
txt = ' '.join(txt.replace('', '').split()[:-1]) + '' txt = ' '.join(txt.replace('', '').split()[:-1]) + ''
p = PlainTextParagraph(txt, style=self.stylesheet['Normal']) p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(self.event_width, self.event_height)
return txt return txt
@@ -563,7 +572,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
else: else:
p_str = shorten(self.invoice.event.name) p_str = shorten(self.invoice.event.name)
p = PlainTextParagraph(p_str, style=self.stylesheet['Normal']) p = FontFallbackParagraph(self._normalize(p_str.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.event_width, self.event_height) p.wrapOn(canvas, self.event_width, self.event_height)
p_size = p.wrap(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]) p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
@@ -636,37 +645,39 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
type_info_text = self.invoice.transmission_type_instance.pdf_info_text() type_info_text = self.invoice.transmission_type_instance.pdf_info_text()
if type_info_text: if type_info_text:
story.append(PlainTextParagraph( story.append(FontFallbackParagraph(
type_info_text, type_info_text,
self.stylesheet['WarningBlock'] self.stylesheet['WarningBlock']
)) ))
if self.invoice.custom_field: if self.invoice.custom_field:
story.append(PlainTextParagraph( story.append(FontFallbackParagraph(
'{}: {}'.format( '{}: {}'.format(
str(self.invoice.event.settings.invoice_address_custom_field), self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)),
self.invoice.custom_field, self._clean_text(self.invoice.custom_field),
), ),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
if self.invoice.internal_reference: if self.invoice.internal_reference:
story.append(PlainTextParagraph( story.append(FontFallbackParagraph(
pgettext('invoice', 'Customer reference: {reference}').format( self._normalize(pgettext('invoice', 'Customer reference: {reference}').format(
reference=self.invoice.internal_reference, reference=self._clean_text(self.invoice.internal_reference),
), )),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
if self.invoice.invoice_to_vat_id: if self.invoice.invoice_to_vat_id:
story.append(PlainTextParagraph( story.append(FontFallbackParagraph(
pgettext('invoice', 'Customer VAT ID') + ': ' + self.invoice.invoice_to_vat_id, self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' +
self._clean_text(self.invoice.invoice_to_vat_id),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
if self.invoice.invoice_to_beneficiary: if self.invoice.invoice_to_beneficiary:
story.append(PlainTextParagraph( story.append(FontFallbackParagraph(
pgettext('invoice', 'Beneficiary') + ':\n' + self.invoice.invoice_to_beneficiary, self._normalize(pgettext('invoice', 'Beneficiary')) + ':<br />' +
self._clean_text(self.invoice.invoice_to_beneficiary),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
@@ -696,11 +707,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story = [ story = [
NextPageTemplate('FirstPage'), NextPageTemplate('FirstPage'),
PlainTextParagraph( FontFallbackParagraph(
( self._normalize(
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU' pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
else pgettext('invoice', 'Invoice') else pgettext('invoice', 'Invoice')
) if not self.invoice.is_cancellation else pgettext('invoice', 'Cancellation'), ) if not self.invoice.is_cancellation else self._normalize(pgettext('invoice', 'Cancellation')),
self.stylesheet['Heading1'] self.stylesheet['Heading1']
), ),
Spacer(1, 5 * mm), Spacer(1, 5 * mm),
@@ -722,17 +733,17 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
] ]
if has_taxes: if has_taxes:
tdata = [( tdata = [(
PlainTextParagraph(pgettext('invoice', 'Description'), self.stylesheet['Bold']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
PlainTextParagraph(pgettext('invoice', 'Qty'), self.stylesheet['BoldRightNoSplit']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Tax rate'), self.stylesheet['BoldRightNoSplit']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Net'), self.stylesheet['BoldRightNoSplit']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Gross'), self.stylesheet['BoldRightNoSplit']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']),
)] )]
else: else:
tdata = [( tdata = [(
PlainTextParagraph(pgettext('invoice', 'Description'), self.stylesheet['Bold']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
PlainTextParagraph(pgettext('invoice', 'Qty'), self.stylesheet['BoldRightNoSplit']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Amount'), self.stylesheet['BoldRightNoSplit']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
)] )]
def _group_key(line): def _group_key(line):
@@ -769,8 +780,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
max_height = self.stylesheet['Normal'].leading * 5 max_height = self.stylesheet['Normal'].leading * 5
p_style = self.stylesheet['Normal'] p_style = self.stylesheet['Normal']
for __ in range(1000): for __ in range(1000):
p = PlainTextParagraph( p = FontFallbackParagraph(
curr_description, self._clean_text(curr_description, tags=['br']),
p_style p_style
) )
h = p.wrap(max_width, doc.height)[1] h = p.wrap(max_width, doc.height)[1]
@@ -851,7 +862,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
# Group together at the end of the invoice # Group together at the end of the invoice
request_show_service_date = period_line request_show_service_date = period_line
elif period_line: elif period_line:
description_p_list.append(PlainTextParagraph( description_p_list.append(FontFallbackParagraph(
period_line, period_line,
self.stylesheet['Fineprint'] self.stylesheet['Fineprint']
)) ))
@@ -863,7 +874,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net_price=money_filter(net_value, self.invoice.event.currency), net_price=money_filter(net_value, self.invoice.event.currency),
gross_price=money_filter(gross_value, self.invoice.event.currency), gross_price=money_filter(gross_value, self.invoice.event.currency),
) )
description_p_list.append(PlainTextParagraph( description_p_list.append(FontFallbackParagraph(
single_price_line, single_price_line,
self.stylesheet['Fineprint'] self.stylesheet['Fineprint']
)) ))
@@ -872,11 +883,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
description_p_list.pop(0), description_p_list.pop(0),
str(len(lines)), str(len(lines)),
localize(tax_rate) + " %", localize(tax_rate) + " %",
PlainTextParagraph( FontFallbackParagraph(
money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight'] self.stylesheet['NormalRight']
), ),
PlainTextParagraph( FontFallbackParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight'] self.stylesheet['NormalRight']
), ),
@@ -893,14 +904,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
single_price_line = pgettext('invoice', 'Single price: {price}').format( single_price_line = pgettext('invoice', 'Single price: {price}').format(
price=money_filter(gross_value, self.invoice.event.currency), price=money_filter(gross_value, self.invoice.event.currency),
) )
description_p_list.append(PlainTextParagraph( description_p_list.append(FontFallbackParagraph(
single_price_line, single_price_line,
self.stylesheet['Fineprint'] self.stylesheet['Fineprint']
)) ))
tdata.append(( tdata.append((
description_p_list.pop(0), description_p_list.pop(0),
str(len(lines)), str(len(lines)),
PlainTextParagraph( FontFallbackParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight'] self.stylesheet['NormalRight']
), ),
@@ -933,12 +944,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if has_taxes: if has_taxes:
tdata.append([ tdata.append([
PlainTextParagraph(pgettext('invoice', 'Invoice total'), self.stylesheet['Bold']), '', '', '', FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
money_filter(total, self.invoice.event.currency) money_filter(total, self.invoice.event.currency)
]) ])
else: else:
tdata.append([ tdata.append([
PlainTextParagraph(pgettext('invoice', 'Invoice total'), self.stylesheet['Bold']), '', FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
money_filter(total, self.invoice.event.currency) money_filter(total, self.invoice.event.currency)
]) ])
@@ -947,12 +958,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
pending_sum = self.invoice.order.pending_sum pending_sum = self.invoice.order.pending_sum
if pending_sum != total: if pending_sum != total:
tdata.append( tdata.append(
[PlainTextParagraph(pgettext('invoice', 'Received payments'), self.stylesheet['Normal'])] + [FontFallbackParagraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] +
(['', '', ''] if has_taxes else ['']) + (['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum - total, self.invoice.event.currency)] [money_filter(pending_sum - total, self.invoice.event.currency)]
) )
tdata.append( tdata.append(
[PlainTextParagraph(pgettext('invoice', 'Outstanding payments'), self.stylesheet['Bold'])] + [FontFallbackParagraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) + (['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum, self.invoice.event.currency)] [money_filter(pending_sum, self.invoice.event.currency)]
) )
@@ -969,12 +980,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
s=Sum('amount') s=Sum('amount')
)['s'] or Decimal('0.00') )['s'] or Decimal('0.00')
tdata.append( tdata.append(
[PlainTextParagraph(pgettext('invoice', 'Paid by gift card'), self.stylesheet['Normal'])] + [FontFallbackParagraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] +
(['', '', ''] if has_taxes else ['']) + (['', '', ''] if has_taxes else ['']) +
[money_filter(giftcard_sum, self.invoice.event.currency)] [money_filter(giftcard_sum, self.invoice.event.currency)]
) )
tdata.append( tdata.append(
[PlainTextParagraph(pgettext('invoice', 'Remaining amount'), self.stylesheet['Bold'])] + [FontFallbackParagraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) + (['', '', ''] if has_taxes else ['']) +
[money_filter(total - giftcard_sum, self.invoice.event.currency)] [money_filter(total - giftcard_sum, self.invoice.event.currency)]
) )
@@ -997,14 +1008,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Spacer(1, 10 * mm)) story.append(Spacer(1, 10 * mm))
if request_show_service_date: if request_show_service_date:
story.append(PlainTextParagraph( story.append(FontFallbackParagraph(
pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date), self._normalize(pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date)),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
if self.invoice.payment_provider_text: if self.invoice.payment_provider_text:
story.append(FontFallbackParagraph( story.append(FontFallbackParagraph(
self._clean_text(self.invoice.payment_provider_text, tags=['br', 'b']), self._normalize(self.invoice.payment_provider_text),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
@@ -1028,10 +1039,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
('FONTNAME', (0, 0), (-1, -1), self.font_regular), ('FONTNAME', (0, 0), (-1, -1), self.font_regular),
] ]
thead = [ thead = [
PlainTextParagraph(pgettext('invoice', 'Tax rate'), self.stylesheet['Fineprint']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']),
PlainTextParagraph(pgettext('invoice', 'Net value'), self.stylesheet['FineprintRight']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']),
PlainTextParagraph(pgettext('invoice', 'Gross value'), self.stylesheet['FineprintRight']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']),
PlainTextParagraph(pgettext('invoice', 'Tax'), self.stylesheet['FineprintRight']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']),
'' ''
] ]
tdata = [thead] tdata = [thead]
@@ -1042,7 +1053,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
continue continue
tax = taxvalue_map[idx] tax = taxvalue_map[idx]
tdata.append([ tdata.append([
PlainTextParagraph(localize(rate) + " % " + name, self.stylesheet['Fineprint']), FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
money_filter(gross - tax, self.invoice.event.currency), money_filter(gross - tax, self.invoice.event.currency),
money_filter(gross, self.invoice.event.currency), money_filter(gross, self.invoice.event.currency),
money_filter(tax, self.invoice.event.currency), money_filter(tax, self.invoice.event.currency),
@@ -1061,7 +1072,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
table.setStyle(TableStyle(tstyledata)) table.setStyle(TableStyle(tstyledata))
story.append(Spacer(5 * mm, 5 * mm)) story.append(Spacer(5 * mm, 5 * mm))
story.append(KeepTogether([ story.append(KeepTogether([
PlainTextParagraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']), FontFallbackParagraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']),
table table
])) ]))
@@ -1078,7 +1089,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net = gross - tax net = gross - tax
tdata.append([ tdata.append([
PlainTextParagraph(localize(rate) + " % " + name, self.stylesheet['Fineprint']), FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
fmt(net), fmt(gross), fmt(tax), '' fmt(net), fmt(gross), fmt(tax), ''
]) ])
@@ -1087,13 +1098,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(KeepTogether([ story.append(KeepTogether([
Spacer(1, height=2 * mm), Spacer(1, height=2 * mm),
PlainTextParagraph( FontFallbackParagraph(
pgettext( self._normalize(pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on ' 'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, this corresponds to:' '{date}, this corresponds to:'
).format(rate=localize(self.invoice.foreign_currency_rate), ).format(rate=localize(self.invoice.foreign_currency_rate),
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"), authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT")), date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"))),
self.stylesheet['Fineprint'] self.stylesheet['Fineprint']
), ),
Spacer(1, height=3 * mm), Spacer(1, height=3 * mm),
@@ -1102,14 +1113,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate: elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate) foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
story.append(Spacer(1, 5 * mm)) story.append(Spacer(1, 5 * mm))
story.append(PlainTextParagraph( story.append(FontFallbackParagraph(self._normalize(
pgettext( pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on ' 'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, the invoice total corresponds to {total}.' '{date}, the invoice total corresponds to {total}.'
).format(rate=localize(self.invoice.foreign_currency_rate), ).format(rate=localize(self.invoice.foreign_currency_rate),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"), date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"),
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"), authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
total=fmt(foreign_total)), total=fmt(foreign_total))),
self.stylesheet['Fineprint'] self.stylesheet['Fineprint']
)) ))
@@ -1151,8 +1162,11 @@ class Modern1Renderer(ClassicInvoiceRenderer):
def _draw_invoice_from(self, canvas): def _draw_invoice_from(self, canvas):
if not self.invoice.address_invoice_from: if not self.invoice.address_invoice_from:
return return
c = self.invoice.address_invoice_from.strip().split('\n') c = [
p = PlainTextParagraph(' · '.join(c), style=self.stylesheet['Sender']) 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.wrapOn(canvas, self.invoice_to_width, 15.7 * mm) 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) p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
super()._draw_invoice_from(canvas) super()._draw_invoice_from(canvas)
@@ -1211,8 +1225,8 @@ class Modern1Renderer(ClassicInvoiceRenderer):
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs) _draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs)
] ]
p = PlainTextParagraph( p = FontFallbackParagraph(
date_format(self.invoice.date, "DATE_FORMAT"), 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) style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
) )
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize) w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
@@ -1269,7 +1283,7 @@ class Modern1SimplifiedRenderer(Modern1Renderer):
i = [] i = []
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage: if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
i.append(PlainTextParagraph( i.append(FontFallbackParagraph(
pgettext('invoice', 'Event date: {date_range}').format( pgettext('invoice', 'Event date: {date_range}').format(
date_range=self.invoice.event.get_date_range_display(), date_range=self.invoice.event.get_date_range_display(),
), ),
@@ -1,29 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Do nothing. Useful for startup performance testing."
def handle(self, *args, **options):
pass
@@ -40,7 +40,6 @@ from django.core.cache import cache
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import close_old_connections from django.db import close_old_connections
from django.dispatch.dispatcher import NO_RECEIVERS from django.dispatch.dispatcher import NO_RECEIVERS
from django_querytagger.tagging import with_tag
from pretix.helpers.periodic import SKIPPED from pretix.helpers.periodic import SKIPPED
@@ -83,8 +82,7 @@ class Command(BaseCommand):
try: try:
# Check if the DB connection is still good, it might be closed if the previous task took too long. # Check if the DB connection is still good, it might be closed if the previous task took too long.
close_old_connections() close_old_connections()
with with_tag(f"periodictask={name}"): r = receiver(signal=periodic_task, sender=self)
r = receiver(signal=periodic_task, sender=self)
except Exception as err: except Exception as err:
if isinstance(err, KeyboardInterrupt): if isinstance(err, KeyboardInterrupt):
raise err raise err
@@ -44,8 +44,7 @@ class Command(Parent):
# Start the vite server in the background # Start the vite server in the background
vite_server = subprocess.Popen( vite_server = subprocess.Popen(
["npm", "run", "dev:control"], ["npm", "run", "dev:control"],
cwd=Path(__file__).parent.parent.parent.parent.parent, cwd=Path(__file__).parent.parent.parent.parent.parent
stdin=subprocess.DEVNULL
) )
def cleanup(): def cleanup():
+14 -20
View File
@@ -26,7 +26,6 @@ from django.utils.translation import gettext_lazy as _
class BaseMediaType: class BaseMediaType:
medium_created_by_server = False medium_created_by_server = False
medium_created_from_unknown_supported = False
supports_orderposition = False supports_orderposition = False
supports_giftcard = False supports_giftcard = False
@@ -57,7 +56,7 @@ class BaseMediaType:
def is_active(self, organizer): def is_active(self, organizer):
return organizer.settings.get(f'reusable_media_type_{self.identifier}', as_type=bool, default=False) return organizer.settings.get(f'reusable_media_type_{self.identifier}', as_type=bool, default=False)
def handle_unknown(self, organizer, identifier, user, auth, force_create=False): def handle_unknown(self, organizer, identifier, user, auth):
pass pass
def handle_new(self, organizer, medium, user, auth): def handle_new(self, organizer, medium, user, auth):
@@ -89,32 +88,23 @@ class NfcUidMediaType(BaseMediaType):
verbose_name = _('NFC UID-based') verbose_name = _('NFC UID-based')
icon = 'pretixbase/img/media/nfc_uid.svg' icon = 'pretixbase/img/media/nfc_uid.svg'
medium_created_by_server = False medium_created_by_server = False
medium_created_from_unknown_supported = True
supports_giftcard = True supports_giftcard = True
supports_orderposition = True supports_orderposition = False
def handle_unknown(self, organizer, identifier, user, auth, force_create=False): def handle_unknown(self, organizer, identifier, user, auth):
from pretix.base.models import GiftCard, ReusableMedium from pretix.base.models import GiftCard, ReusableMedium
create_giftcard = organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool) if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool):
if create_giftcard or force_create:
if identifier.startswith("08"): if identifier.startswith("08"):
# Don't create gift cards for NFC UIDs that start with 08, which represents NFC cards that issue random # Don't create gift cards for NFC UIDs that start with 08, which represents NFC cards that issue random
# UIDs on every read, so they won't be useful. # UIDs on every read, so they won't be useful.
return return
with transaction.atomic(): with transaction.atomic():
if create_giftcard: gc = GiftCard.objects.create(
gc = GiftCard.objects.create( issuer=organizer,
issuer=organizer, expires=organizer.default_gift_card_expiry,
expires=organizer.default_gift_card_expiry, currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'),
currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'), )
)
gc.log_action(
'pretix.giftcards.created',
user=user, auth=auth,
)
else:
gc = None
m = ReusableMedium.objects.create( m = ReusableMedium.objects.create(
type=self.identifier, type=self.identifier,
identifier=identifier, identifier=identifier,
@@ -126,6 +116,10 @@ class NfcUidMediaType(BaseMediaType):
'pretix.reusable_medium.created.auto', 'pretix.reusable_medium.created.auto',
user=user, auth=auth, user=user, auth=auth,
) )
gc.log_action(
'pretix.giftcards.created',
user=user, auth=auth,
)
return m return m
@@ -135,7 +129,7 @@ class NfcMf0aesMediaType(BaseMediaType):
icon = 'pretixbase/img/media/nfc_secure.svg' icon = 'pretixbase/img/media/nfc_secure.svg'
medium_created_by_server = False medium_created_by_server = False
supports_giftcard = True supports_giftcard = True
supports_orderposition = True supports_orderposition = False
def handle_new(self, organizer, medium, user, auth): def handle_new(self, organizer, medium, user, auth):
from pretix.base.models import GiftCard from pretix.base.models import GiftCard
+2 -4
View File
@@ -282,12 +282,10 @@ def metric_values():
# Throwaway metrics # Throwaway metrics
exact_tables = [ exact_tables = [
Order, Invoice, Event, Organizer Order, OrderPosition, Invoice, Event, Organizer
] ]
for m in apps.get_models(): # Count all models for m in apps.get_models(): # Count all models
if issubclass(m, OrderPosition): if any(issubclass(m, p) for p in exact_tables):
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.all.count()
elif any(issubclass(m, p) for p in exact_tables):
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count() metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count()
else: else:
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = estimate_count_fast(m) metrics['pretix_model_instances']['{model="%s"}' % m._meta] = estimate_count_fast(m)
+60 -197
View File
@@ -19,10 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import base64
import hashlib
import logging
import re
from collections import OrderedDict from collections import OrderedDict
from urllib.parse import urlparse, urlsplit from urllib.parse import urlparse, urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -47,8 +43,6 @@ from pretix.multidomain.urlreverse import (
) )
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__)
_supported = None _supported = None
@@ -71,49 +65,15 @@ def get_supported_language(requested_language, allowed_languages, default_langua
return language return language
class BaseLocaleMiddleware(MiddlewareMixin):
"""
This is a reduced LocaleMiddleware that uses only information contained in the WSGI request data
to figure out the language (cookie and browser settings). We need it to have a consistent language
for error pages that are generated from the middleware stack before we know e.g. which user is logged
in or which event is selected.
"""
def process_request(self, request: HttpRequest):
language = get_language_from_early_request(request)
translation.activate(language)
set_region(None)
request.LANGUAGE_CODE = language
timezone.deactivate()
def process_response(self, request: HttpRequest, response: HttpResponse):
language = translation.get_language()
patch_vary_headers(response, ('Accept-Language',))
if 'Content-Language' not in response:
response['Content-Language'] = language
return response
class LocaleMiddleware(MiddlewareMixin): class LocaleMiddleware(MiddlewareMixin):
""" """
This is the full LocaleMiddleware that uses all available information to figure out the correct This middleware sets the correct locale and timezone
language for the request using all available sources, in this order of priority: for a request.
- Backend: User settings
- Language cookie
- Frontend: Customer account settings
- Browser settings
- Frontend: Event/Organizer settings
- System default
It needs to run late in the middleware stack to have all information available for these steps.
For some cases, it is even ran a second time since the event is sometimes only figured out after the
middleware stack (can happen for plugin views).
""" """
def process_request(self, request: HttpRequest): def process_request(self, request: HttpRequest):
language = get_language_from_request(request) language = get_language_from_request(request)
region = None
# Normally, this middleware runs *before* the event is set. However, on event frontend pages it # Normally, this middleware runs *before* the event is set. However, on event frontend pages it
# might be run a second time by pretix.presale.EventMiddleware and in this case the event is already # might be run a second time by pretix.presale.EventMiddleware and in this case the event is already
# set and can be taken into account for the decision. # set and can be taken into account for the decision.
@@ -134,16 +94,15 @@ class LocaleMiddleware(MiddlewareMixin):
if '-' not in language and settings_holder.settings.region: if '-' not in language and settings_holder.settings.region:
language += '-' + settings_holder.settings.region language += '-' + settings_holder.settings.region
if settings_holder.settings.region: if settings_holder.settings.region:
region = settings_holder.settings.region set_region(settings_holder.settings.region)
else: else:
gs = global_settings_object(request) gs = global_settings_object(request)
if '-' not in language and gs.settings.region: if '-' not in language and gs.settings.region:
language += '-' + gs.settings.region language += '-' + gs.settings.region
if gs.settings.region: if gs.settings.region:
region = gs.settings.region set_region(gs.settings.region)
translation.activate(language) translation.activate(language)
set_region(region)
request.LANGUAGE_CODE = get_language_without_region() request.LANGUAGE_CODE = get_language_without_region()
tzname = None tzname = None
@@ -223,24 +182,6 @@ def get_default_language():
return settings.LANGUAGE_CODE return settings.LANGUAGE_CODE
def get_language_from_early_request(request: HttpRequest) -> str:
"""
Analyzes the request to find what language the user wants the system to
show using only WSGI-available information. Only languages listed in
settings.LANGUAGES are taken into account. If the user requests a sublanguage
where we have a main language, we send out the main language.
"""
global _supported
if _supported is None:
_supported = OrderedDict(settings.LANGUAGES)
return (
get_language_from_cookie(request)
or get_language_from_browser(request)
or get_default_language()
)
def get_language_from_request(request: HttpRequest) -> str: def get_language_from_request(request: HttpRequest) -> str:
""" """
Analyzes the request to find what language the user wants the system to Analyzes the request to find what language the user wants the system to
@@ -255,6 +196,7 @@ def get_language_from_request(request: HttpRequest) -> str:
if request.path.startswith(get_script_prefix() + 'control'): if request.path.startswith(get_script_prefix() + 'control'):
return ( return (
get_language_from_user_settings(request) get_language_from_user_settings(request)
or get_language_from_customer_settings(request)
or get_language_from_cookie(request) or get_language_from_cookie(request)
or get_language_from_browser(request) or get_language_from_browser(request)
or get_language_from_event(request) or get_language_from_event(request)
@@ -279,26 +221,7 @@ def _parse_csp(header):
return h return h
VALID_CSP_DIRECTIVES = [
"child-src", "connect-src", "default-src", "fenced-frame-src", "font-src", "form-action", "frame-src", "img-src",
"manifest-src", "media-src", "object-src", "prefetch-src", "report-uri", "script-src", "script-src-elem",
"script-src-attr", "style-src", "style-src-elem", "style-src-attr", "worker-src",
]
CSP_ILLEGAL_CHARS = re.compile(r'[\s,;]')
def _sanitize_csp(h):
for k, v in h.items():
if k not in VALID_CSP_DIRECTIVES:
raise ValueError("Invalid CSP directive " + k)
if any(CSP_ILLEGAL_CHARS.search(el) for el in v):
logger.warning("Stripping invalid component from CSP: %r", h)
h[k] = [el for el in v if not CSP_ILLEGAL_CHARS.search(el)]
def _render_csp(h): def _render_csp(h):
_sanitize_csp(h)
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v) return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v)
@@ -314,51 +237,25 @@ def _merge_csp(a, b):
for k, v in a.items(): for k, v in a.items():
if "'unsafe-inline'" in v: if "'unsafe-inline'" in v:
# If we need unsafe-inline, drop any hashes or nonce as they will be ignored otherwise # If we need unsafe-inline, drop any hashes or nonce as they will be ignored otherwise
a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha256-")] a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha-")]
def add_to_response_csp(response, csp_to_merge):
if "Content-Security-Policy" in response:
csp = _parse_csp(response["Content-Security-Policy"])
else:
csp = {}
_merge_csp(csp, csp_to_merge)
if csp:
response["Content-Security-Policy"] = _render_csp(csp)
def add_to_response_csp_via_request(request, csp_to_merge):
_merge_csp(request._csp_to_merge, csp_to_merge)
def calculate_csp_hash(data):
hash_str = base64.b64encode(hashlib.sha256(data.encode("utf-8")).digest()).decode("ascii")
return f"'sha256-{hash_str}'"
class SecurityMiddleware(MiddlewareMixin): class SecurityMiddleware(MiddlewareMixin):
SAFE_TYPES = ( CSP_EXEMPT = (
# CSP policies are only used for: '/api/v1/docs/',
# - HTML and SVG in top-level contexts
# - SVG or JS Workers delivered in embedded contexts
# See: https://www.w3.org/TR/CSP2/#which-policy-applies
# Therefore, we can save bandwidth on not including our (sometimes huge) policy
# on API responses or CSS. We do however include it with other types as a precaution
# (whitelist instead of blacklist) and we also do not whitelist JavaScript in
# we ever add service workers to not break the protection of this feature:
# https://www.w3.org/TR/CSP2/#sandboxing-and-workers
'application/json',
'text/css',
# We used to skip CSP for PDF since it was necessary for inline previews in Safari,
# but at the moment it does not seem to be an issue to just send it.
) )
def process_request(self, request):
request._csp_to_merge = {}
def process_response(self, request, resp): def process_response(self, request, resp):
def nested_dict_values(d):
for v in d.values():
if isinstance(v, dict):
yield from nested_dict_values(v)
else:
if isinstance(v, str):
yield v
url = resolve(request.path_info)
if settings.DEBUG and resp.status_code >= 400: if settings.DEBUG and resp.status_code >= 400:
# Don't use CSP on debug error page as it breaks of Django's fancy error # Don't use CSP on debug error page as it breaks of Django's fancy error
# pages # pages
@@ -369,24 +266,18 @@ class SecurityMiddleware(MiddlewareMixin):
# https://github.com/pretix/pretix/issues/765 # https://github.com/pretix/pretix/issues/765
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"' resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
if self._needs_csp(request, resp): img_src = []
resp['Content-Security-Policy'] = _render_csp(self._build_csp(request, resp)) gs = global_settings_object(request)
elif 'Content-Security-Policy' in resp: if gs.settings.leaflet_tiles:
del resp['Content-Security-Policy'] img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
return resp font_src = set()
if hasattr(request, 'event'):
def _needs_csp(self, request, resp): for font in get_fonts(request.event, pdf_support_required=False).values():
if "Content-Type" in resp and resp["Content-Type"].split(";")[0] in self.SAFE_TYPES: for path in list(nested_dict_values(font)):
return False font_location = urlparse(path)
if font_location.scheme and font_location.netloc:
if getattr(resp, '_csp_ignore', False): font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
return False
return True
def _build_csp(self, request, resp):
url = resolve(request.path_info)
h = { h = {
'default-src': ["{static}"], 'default-src': ["{static}"],
@@ -394,9 +285,9 @@ class SecurityMiddleware(MiddlewareMixin):
'object-src': ["'none'"], 'object-src': ["'none'"],
'frame-src': ['{static}'], 'frame-src': ['{static}'],
'style-src': ["{static}", "{media}"], 'style-src': ["{static}", "{media}"],
'connect-src': ["{static}", "{dynamic}", "{media}"], 'connect-src': ["{dynamic}", "{media}"],
'img-src': ["{static}", "{media}", "data:"], 'img-src': ["{static}", "{media}", "data:"] + img_src,
'font-src': ["{static}"], 'font-src': ["{static}"] + list(font_src),
'media-src': ["{static}", "data:"], 'media-src': ["{static}", "data:"],
# form-action is not only used to match on form actions, but also on URLs # form-action is not only used to match on form actions, but also on URLs
# form-actions redirect to. In the context of e.g. payment providers or # form-actions redirect to. In the context of e.g. payment providers or
@@ -405,18 +296,17 @@ class SecurityMiddleware(MiddlewareMixin):
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []), 'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
} }
gs = global_settings_object(request)
if gs.settings.leaflet_tiles:
h['img-src'].append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
if hasattr(request, 'event'):
h['font-src'] += list(self._get_font_origins(request.event))
if settings.VITE_DEV_MODE: if settings.VITE_DEV_MODE:
h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"] h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"]
h['style-src'] += ["'unsafe-inline'"] h['style-src'] += ["'unsafe-inline'"]
h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"] h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"]
if hasattr(request, 'csp_nonce'):
nonce = f"'nonce-{request.csp_nonce}'"
h['script-src'].append(nonce)
if not settings.VITE_DEV_MODE:
# can't have 'unsafe-inline' and nonce at the same time
h['style-src'].append(nonce)
# Only include pay.google.com for wallet detection purposes on the Payment selection page # Only include pay.google.com for wallet detection purposes on the Payment selection page
if ( if (
url.url_name == "event.order.pay.change" or url.url_name == "event.order.pay.change" or
@@ -425,35 +315,27 @@ class SecurityMiddleware(MiddlewareMixin):
h['script-src'].append('https://pay.google.com') h['script-src'].append('https://pay.google.com')
h['frame-src'].append('https://pay.google.com') h['frame-src'].append('https://pay.google.com')
h['connect-src'].append('https://google.com/pay') h['connect-src'].append('https://google.com/pay')
if settings.LOG_CSP: if settings.LOG_CSP:
h['report-uri'] = ["/csp_report/"] h['report-uri'] = ["/csp_report/"]
if request._csp_to_merge:
_merge_csp(h, request._csp_to_merge)
if 'Content-Security-Policy' in resp: if 'Content-Security-Policy' in resp:
_merge_csp(h, _parse_csp(resp['Content-Security-Policy'])) _merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
if settings.CSP_ADDITIONAL_HEADER: if settings.CSP_ADDITIONAL_HEADER:
_merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER)) _merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER))
placeholders = { staticdomain = "'self'"
"{static}": ["'self'"], dynamicdomain = "'self'"
"{dynamic}": ["'self'"], mediadomain = "'self'"
"{media}": ["'self'"],
}
if settings.MEDIA_URL.startswith('http'): if settings.MEDIA_URL.startswith('http'):
placeholders["{media}"].append(settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)]) mediadomain += " " + settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)]
if settings.STATIC_URL.startswith('http'): if settings.STATIC_URL.startswith('http'):
placeholders["{static}"].append(settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)]) staticdomain += " " + settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)]
if settings.SITE_URL.startswith('http'): if settings.SITE_URL.startswith('http'):
if settings.SITE_URL.find('/', 9) > 0: if settings.SITE_URL.find('/', 9) > 0:
placeholders["{static}"].append(settings.SITE_URL[:settings.SITE_URL.find('/', 9)]) staticdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
placeholders["{dynamic}"].append(settings.SITE_URL[:settings.SITE_URL.find('/', 9)]) dynamicdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
else: else:
placeholders["{static}"].append(settings.SITE_URL) staticdomain += " " + settings.SITE_URL
placeholders["{dynamic}"].append(settings.SITE_URL) dynamicdomain += " " + settings.SITE_URL
if hasattr(request, 'organizer') and request.organizer: if hasattr(request, 'organizer') and request.organizer:
if hasattr(request, 'event') and request.event: if hasattr(request, 'event') and request.event:
@@ -464,29 +346,18 @@ class SecurityMiddleware(MiddlewareMixin):
siteurlsplit = urlsplit(settings.SITE_URL) siteurlsplit = urlsplit(settings.SITE_URL)
if siteurlsplit.port and siteurlsplit.port not in (80, 443): if siteurlsplit.port and siteurlsplit.port not in (80, 443):
domain = '%s:%d' % (domain, siteurlsplit.port) domain = '%s:%d' % (domain, siteurlsplit.port)
placeholders["{dynamic}"].append(domain) dynamicdomain += " " + domain
for k, v in h.items(): if request.path not in self.CSP_EXEMPT and not getattr(resp, '_csp_ignore', False):
h[k] = sorted(set(result for part in v for result in placeholders.get(part, [part]))) resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain,
media=mediadomain)
for k, v in h.items():
h[k] = sorted(set(' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' ')))
resp['Content-Security-Policy'] = _render_csp(h)
elif 'Content-Security-Policy' in resp:
del resp['Content-Security-Policy']
return h return resp
def _get_font_origins(self, event):
def nested_dict_values(d):
for v in d.values():
if isinstance(v, dict):
yield from nested_dict_values(v)
else:
if isinstance(v, str):
yield v
font_src = set()
for font in get_fonts(event, pdf_support_required=False).values():
for path in list(nested_dict_values(font)):
font_location = urlparse(path)
if font_location.scheme and font_location.netloc:
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
return font_src
class RejectInvalidInputMiddleware(MiddlewareMixin): class RejectInvalidInputMiddleware(MiddlewareMixin):
@@ -497,16 +368,8 @@ class RejectInvalidInputMiddleware(MiddlewareMixin):
if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']: if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']:
raise BadRequest("Invalid characters in input.") raise BadRequest("Invalid characters in input.")
if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded": if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded":
try: if any("\x00" in value for key, value_list in request.POST.lists() for value in value_list):
post_data = request.POST.lists() raise BadRequest("Invalid characters in input.")
except BadRequest:
# Reading request.POST wasn't possible, probably an invalid charset. Django will crash once we actually
# use request.POST, but if we don't, let's not crash it (required for some weird payment provider
# webhooks, e.g. computop).
pass
else:
if any("\x00" in value for key, value_list in post_data for value in value_list):
raise BadRequest("Invalid characters in input.")
class CustomCommonMiddleware(CommonMiddleware): class CustomCommonMiddleware(CommonMiddleware):
@@ -1,35 +0,0 @@
# Generated by Django 4.2.26 on 2025-11-24 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0299_itemprogramtime_location"),
]
operations = [
migrations.AddField(
model_name="reusablemedium",
name="claim_token",
field=models.CharField(max_length=200, null=True),
),
migrations.AddField(
model_name="reusablemedium",
name="label",
field=models.CharField(max_length=200, null=True),
),
# use temporary related_name "linked_mediums" for ManyToManyField, so we can migrate existing data
migrations.AddField(
model_name="reusablemedium",
name="linked_orderpositions",
field=models.ManyToManyField(
related_name="linked_mediums", to="pretixbase.orderposition"
),
),
migrations.RunSQL(
sql="INSERT INTO pretixbase_reusablemedium_linked_orderpositions (reusablemedium_id, orderposition_id) SELECT id, linked_orderposition_id FROM pretixbase_reusablemedium WHERE linked_orderposition_id IS NOT NULL;",
reverse_sql="DELETE FROM pretixbase_reusablemedium_linked_orderpositions;",
),
]
@@ -1,44 +0,0 @@
# Generated by Django 4.2.26 on 2025-11-24 11:32
from django.db import migrations, models
def reverse(apps, schema_editor):
ReusableMedium = apps.get_model('pretixbase', 'ReusableMedium')
qs = ReusableMedium.linked_orderpositions.through.objects
objs = []
# get last added orderposition from linked_orderpositions
for rm_id, op_id in qs.filter(id__in=qs.values("reusablemedium_id").annotate(max_id=models.Max('id')).values('max_id')).values_list("reusablemedium_id", "orderposition_id"):
obj = ReusableMedium(
id=rm_id,
linked_orderposition_id=op_id,
)
objs.append(obj)
ReusableMedium.objects.bulk_update(objs, ['linked_orderposition_id'])
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0300_add_reusablemedium_label"),
]
operations = [
# according to the docs, UPDATE FROM should run similarly on sqlite and postgres, but I could not get it to work
# so roll back the data migration with code before deleting data from through-table in 0297
migrations.RunPython(migrations.RunPython.noop, reverse),
migrations.RemoveField(
model_name="reusablemedium",
name="linked_orderposition",
),
# change related_name for new ManyToManyField to previously used linked_media
migrations.AlterField(
model_name="reusablemedium",
name="linked_orderpositions",
field=models.ManyToManyField(
related_name="linked_media", to="pretixbase.orderposition"
),
),
]
@@ -1,58 +0,0 @@
# Generated by Django 4.2.8 on 2024-07-01 09:27
import logging
from django.db import migrations
from django.db.models import Count
logger = logging.getLogger(__name__)
def clean_duplicate_secrets(apps, schema_editor):
# This will autofix all possible duplicate Order.code and OrderPosition.secret values,
# unless Order.code is already too long to append something. This would need to be fixed by
# sysadmins manually.
OrderPosition = apps.get_model("pretixbase", "OrderPosition")
Order = apps.get_model("pretixbase", "Order")
qs = OrderPosition.all.values("secret", "order__event__organizer_id").order_by().annotate(c=Count("*")).filter(c__gt=1)
for row in qs:
affected = OrderPosition.all.filter(
**{k: v for k, v in row.items() if k != "c"}
).order_by("pk")
logger.error(f"Found {row['c']} tickets with with the same secret \"{row['secret']}\" in organizer {row['order__event__organizer_id']}, all except one will be changed")
for i, a in enumerate(affected):
if i > 0:
a.secret = a.secret + "__dupl__" + str(a.pk)
logger.info(
f"Ticket {a.pk} has new secret {a.secret}"
)
a.save(update_fields=["organizer_id", "secret"])
qs = Order.objects.values("code", "event__organizer_id").order_by().annotate(c=Count("*")).filter(c__gt=1)
for row in qs:
affected = Order.objects.filter(
**{k: v for k, v in row.items() if k != "c"}
).order_by("pk")
logger.error(f"Found {row['c']} orders with with the same code \"{row['code']}\" in organizer {row['event__organizer_id']}, all except one will be changed")
for i, a in enumerate(affected):
if i > 0:
if len(a.code) > 16 - len(str(a.pk)):
raise ValueError(f"Cannot auto-fix order with duplicate code {a.code}, order code is too long already")
a.code = a.code + str(a.pk).zfill(16 - len(a.code))
logger.info(
f"Order {a.pk} has new code {a.code}"
)
a.save(update_fields=["organizer_id", "code"])
class Migration(migrations.Migration):
dependencies = [
(
"pretixbase",
"0301_reusablemedium_remove_orderposition",
),
]
operations = [
migrations.RunPython(clean_duplicate_secrets, migrations.RunPython.noop),
]
@@ -1,46 +0,0 @@
# Generated by Django 4.2.8 on 2024-07-01 09:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"pretixbase",
"0302_resolve_duplicate_codes_and_secrets",
),
]
operations = [
migrations.RunSQL(
"UPDATE pretixbase_order "
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_event e WHERE e.id = pretixbase_order.event_id) "
"WHERE pretixbase_order.organizer_id IS NULL;",
migrations.RunSQL.noop,
),
migrations.RunSQL(
"UPDATE pretixbase_orderposition "
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_order o LEFT JOIN pretixbase_event e ON e.id = o.event_id WHERE o.id = pretixbase_orderposition.order_id) "
"WHERE pretixbase_orderposition.organizer_id IS NULL;",
migrations.RunSQL.noop,
),
migrations.AlterField(
model_name="order",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="orders",
to="pretixbase.organizer",
),
),
migrations.AlterField(
model_name="orderposition",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="order_positions",
to="pretixbase.organizer",
),
),
]
@@ -1,48 +0,0 @@
# Generated by Django 5.2.12 on 2026-04-15 20:10
from decimal import Decimal
from django.db import migrations, models
import pretix.helpers.models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0303_alter_order_organizer_alter_orderposition_organizer'),
]
operations = [
migrations.AlterField(
model_name='cartposition',
name='tax_rate',
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, default=Decimal('0'), max_digits=7),
),
migrations.AlterField(
model_name='invoiceline',
name='tax_rate',
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, default=Decimal('0'), max_digits=7),
),
migrations.AlterField(
model_name='orderfee',
name='tax_rate',
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, max_digits=7),
),
migrations.AlterField(
model_name='orderposition',
name='tax_rate',
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, max_digits=7),
),
migrations.AlterField(
model_name='transaction',
name='tax_rate',
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, max_digits=7),
),
migrations.AlterField(
model_name='taxrule',
name='rate',
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, max_digits=7),
),
]
@@ -1,91 +0,0 @@
# Generated by Django 5.2.12 on 2026-04-28 11:34
import logging
from django.db import IntegrityError, migrations, transaction
from django.db.models import Count, F
logger = logging.getLogger(__name__)
def fix_cross_organizer_eventmetavalues(apps, schema_editor):
EventMetaProperty = apps.get_model("pretixbase", "EventMetaProperty")
EventMetaValue = apps.get_model("pretixbase", "EventMetaValue")
cross_org_values = EventMetaValue.objects.filter(
event__organizer__pk__ne=F('property__organizer__pk')
).order_by('event__organizer__slug', 'event__slug')
for emv in cross_org_values:
logger.warning("%s", f"Fixing cross-organizer EventMetaValue: {emv.event.organizer.slug}/{emv.event.slug}")
logger.warning(" %s", f"{emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {repr(emv.value)}")
try:
emv.property = emv.event.organizer.meta_properties.get(name=emv.property.name)
if EventMetaValue.objects.filter(event=emv.event, property=emv.property).exists():
correct = EventMetaValue.objects.get(event=emv.event, property=emv.property)
if correct.value != emv.value:
logger.warning(" %s", f"WARN: conflicting EventMetaValue with property in correct organizer already exists, deleting the cross-organizer one")
else:
logger.warning(" %s", f"OK: same-value EventMetaValue with property in correct organizer already exists, deleting the cross-organizer one")
logger.warning(" %s", f"keeping: {correct.property.name}({correct.property.id}@{correct.property.organizer.slug}) = {repr(correct.value)}")
emv.delete()
else:
logger.warning(" %s", f"OK: found existing EventMetaProperty in {emv.event.organizer.slug}, updating reference")
logger.warning(" %s", f"after: {emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {repr(emv.value)}")
emv.save(update_fields=["property"])
except EventMetaProperty.DoesNotExist:
meta_prop = emv.property
meta_prop.pk = None
meta_prop.organizer = emv.event.organizer
meta_prop.filter_public = False
meta_prop.save(force_insert=True)
logger.warning(" %s", f"WARN: found no matching EventMetaProperty, creating")
logger.warning(" %s", f"after: {emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {repr(emv.value)}")
emv.save(update_fields=["property"])
def make_eventmetaproperties_unique(apps, schema_editor):
EventMetaProperty = apps.get_model("pretixbase", "EventMetaProperty")
EventMetaValue = apps.get_model("pretixbase", "EventMetaValue")
duplicates = EventMetaProperty.objects.values('organizer', 'organizer__slug', 'name').annotate(count=Count('id')).filter(count__gt=1)
for dup in duplicates:
logger.warning("%s", f"Fixup duplicate property {dup['organizer__slug']} {dup['name']}")
props = list(EventMetaProperty.objects.filter(organizer=dup['organizer'], name=dup['name']))
target = props[0]
invalid = props[1:]
try:
with transaction.atomic():
affected = EventMetaValue.objects.filter(
event__organizer=dup['organizer'], property__in=invalid
).update(
property=target
)
logger.warning("%s", f" Switching {affected} value(s) over to {target.name}({target.id}@{target.organizer.slug})")
except IntegrityError as e:
logger.warning("%s", f" Failed to switch all value(s) over to {target.name}({target.id}@{target.organizer.slug})")
logger.warning("%s", f" {e}")
for prop in invalid:
newname = f'{prop.name}_DUPLICATE_{prop.id}'
logger.warning("%s", f" Renaming {prop.name}({prop.id}@{prop.organizer.slug}) to {newname}({prop.id}@{prop.organizer.slug})")
prop.name = newname
prop.filter_public = False
prop.save()
else:
for prop in invalid:
logger.warning("%s", f" Deleting {prop.name}({prop.id}@{prop.organizer.slug})")
prop.delete()
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0304_tax_rate_decimals"),
]
operations = [
migrations.RunPython(fix_cross_organizer_eventmetavalues, migrations.RunPython.noop),
migrations.RunPython(make_eventmetaproperties_unique, migrations.RunPython.noop),
]
@@ -1,17 +0,0 @@
# Generated by Django 5.2.12 on 2026-04-28 11:34
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0305_fixup_eventmetaproperties"),
]
operations = [
migrations.AlterUniqueTogether(
name="eventmetaproperty",
unique_together={("organizer", "name")},
),
]
@@ -1,43 +0,0 @@
# Generated by Django 5.2.16 on 2026-08-05 08:00
import django.db.models.deletion
from django.db import migrations, models
import pretix.helpers.database
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0306_alter_eventmetaproperty_unique_together"),
]
operations = [
migrations.CreateModel(
name="DeviceLastSeen",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("last_seen", models.DateTimeField(auto_now=True)),
(
"device",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.device",
),
),
],
),
migrations.AddIndex(
model_name="devicelastseen",
index=pretix.helpers.database.BrinIndexIgnoredOnSQLite(
models.F("last_seen"),
autosummarize=True,
name="pretixbase_device_last_seen",
),
),
]
+17 -17
View File
@@ -57,7 +57,7 @@ from django_otp.models import Device
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.helpers.urls import mainreverse_absolute from pretix.helpers.urls import build_absolute_uri
from ...helpers.countries import FastCountryField from ...helpers.countries import FastCountryField
from ...helpers.u2f import pub_key_from_der, websafe_decode from ...helpers.u2f import pub_key_from_der, websafe_decode
@@ -373,12 +373,12 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
mail( mail(
email or self.email, email or self.email,
_('Changes to your account'), _('Account information changed'),
'pretixcontrol/email/security_notice.txt', 'pretixcontrol/email/security_notice.txt',
{ {
'user': self, 'user': self,
'messages': msg, 'messages': msg,
'url': mainreverse_absolute('control:user.settings'), 'url': build_absolute_uri('control:user.settings'),
'instance': settings.PRETIX_INSTANCE_NAME, 'instance': settings.PRETIX_INSTANCE_NAME,
}, },
event=None, event=None,
@@ -400,13 +400,12 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
with language(self.locale): with language(self.locale):
if reason == 'email_change': if reason == 'email_change':
msg = str(_('To change your email address from {old_email} to {new_email}, use the following code:').format( msg = str(_('to confirm changing your email address from {old_email}\nto {new_email}, use the following code:').format(
old_email=self.email, new_email=email, old_email=self.email, new_email=email,
)) ))
elif reason == 'email_verify': elif reason == 'email_verify':
msg = str(_('To verify your email address {email} on {instance}, use the following code:').format( msg = str(_('to confirm that your email address {email} belongs to your pretix account, use the following code:').format(
email=self.email, email=self.email,
instance=settings.PRETIX_INSTANCE_NAME,
)) ))
else: else:
raise Exception('Invalid confirmation code reason') raise Exception('Invalid confirmation code reason')
@@ -419,7 +418,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
} }
mail( mail(
email or self.email, email or self.email,
_('Your confirmation code'), _('pretix confirmation code'),
'pretixcontrol/email/confirmation_code.txt', 'pretixcontrol/email/confirmation_code.txt',
{ {
'user': self, 'user': self,
@@ -463,13 +462,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
from pretix.base.services.mail import mail from pretix.base.services.mail import mail
mail( mail(
self.email, self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
_('Reset your password'),
'pretixcontrol/email/forgot.txt',
{ {
'instance': settings.PRETIX_INSTANCE_NAME, 'instance': settings.PRETIX_INSTANCE_NAME,
'user': self, 'user': self,
'url': (mainreverse_absolute('control:auth.forgot.recover') 'url': (build_absolute_uri('control:auth.forgot.recover')
+ '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self))) + '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self)))
}, },
None, locale=self.locale, user=self None, locale=self.locale, user=self
@@ -650,22 +647,25 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
id__in=self.teams.filter(TeamQuerySet.organizer_permission_q(permission)).values_list('organizer', flat=True) id__in=self.teams.filter(TeamQuerySet.organizer_permission_q(permission)).values_list('organizer', flat=True)
) )
def has_active_staff_session(self, session_key): def has_active_staff_session(self, session_key=None):
""" """
Returns whether or not a user has an active staff session (formerly known as superuser session) Returns whether or not a user has an active staff session (formerly known as superuser session)
with the given session key. with the given session key.
""" """
return self.get_active_staff_session(session_key) is not None return self.get_active_staff_session(session_key) is not None
def get_active_staff_session(self, session_key): def get_active_staff_session(self, session_key=None):
if not self.is_staff or not session_key: if not self.is_staff:
return None return None
if not hasattr(self, '_staff_session_cache'): if not hasattr(self, '_staff_session_cache'):
self._staff_session_cache = {} self._staff_session_cache = {}
if session_key not in self._staff_session_cache: if session_key not in self._staff_session_cache:
sess = StaffSession.objects.filter( qs = StaffSession.objects.filter(
user=self, date_end__isnull=True, session_key=session_key user=self, date_end__isnull=True
).first() )
if session_key:
qs = qs.filter(session_key=session_key)
sess = qs.first()
if sess: if sess:
if sess.date_start < now() - timedelta(seconds=settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE): if sess.date_start < now() - timedelta(seconds=settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE):
sess.date_end = now() sess.date_end = now()
-6
View File
@@ -346,14 +346,11 @@ class Checkin(models.Model):
REASON_INCOMPLETE = 'incomplete' REASON_INCOMPLETE = 'incomplete'
REASON_ALREADY_REDEEMED = 'already_redeemed' REASON_ALREADY_REDEEMED = 'already_redeemed'
REASON_AMBIGUOUS = 'ambiguous' REASON_AMBIGUOUS = 'ambiguous'
REASON_MEDIUM_INVALID = 'medium_invalid'
REASON_MEDIUM_EXISTS = 'medium_exists'
REASON_ERROR = 'error' REASON_ERROR = 'error'
REASON_BLOCKED = 'blocked' REASON_BLOCKED = 'blocked'
REASON_UNAPPROVED = 'unapproved' REASON_UNAPPROVED = 'unapproved'
REASON_INVALID_TIME = 'invalid_time' REASON_INVALID_TIME = 'invalid_time'
REASON_ANNULLED = 'annulled' REASON_ANNULLED = 'annulled'
REASON_ALREADY_EXCHANGED = 'already_exchanged'
REASONS = ( REASONS = (
(REASON_CANCELED, _('Order canceled')), (REASON_CANCELED, _('Order canceled')),
(REASON_INVALID, _('Unknown ticket')), (REASON_INVALID, _('Unknown ticket')),
@@ -369,9 +366,6 @@ class Checkin(models.Model):
(REASON_UNAPPROVED, _('Order not approved')), (REASON_UNAPPROVED, _('Order not approved')),
(REASON_INVALID_TIME, _('Ticket not valid at this time')), (REASON_INVALID_TIME, _('Ticket not valid at this time')),
(REASON_ANNULLED, _('Check-in annulled')), (REASON_ANNULLED, _('Check-in annulled')),
(REASON_ALREADY_EXCHANGED, _('Ticket already exchanged')),
(REASON_MEDIUM_INVALID, _('Reusable medium invalid')),
(REASON_MEDIUM_EXISTS, _('Reusable medium already exists')),
) )
successful = models.BooleanField( successful = models.BooleanField(
+5 -5
View File
@@ -167,7 +167,7 @@ class Customer(LoggedModel):
def send_security_notice(self, message, email=None): def send_security_notice(self, message, email=None):
from pretix.base.services.mail import SendMailException, mail from pretix.base.services.mail import SendMailException, mail
from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.multidomain.urlreverse import build_absolute_uri
try: try:
with language(self.locale): with language(self.locale):
@@ -178,7 +178,7 @@ class Customer(LoggedModel):
{ {
**self.get_email_context(), **self.get_email_context(),
'message': str(message), 'message': str(message),
'url': eventreverse_absolute(self.organizer, 'presale:organizer.customer.index') 'url': build_absolute_uri(self.organizer, 'presale:organizer.customer.index')
}, },
customer=self, customer=self,
organizer=self.organizer, organizer=self.organizer,
@@ -299,12 +299,12 @@ class Customer(LoggedModel):
def send_activation_mail(self): def send_activation_mail(self):
from pretix.base.services.mail import mail from pretix.base.services.mail import mail
from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.forms.customer import TokenGenerator from pretix.presale.forms.customer import TokenGenerator
ctx = self.get_email_context() ctx = self.get_email_context()
token = TokenGenerator().make_token(self) token = TokenGenerator().make_token(self)
ctx['url'] = eventreverse_absolute( ctx['url'] = build_absolute_uri(
self.organizer, self.organizer,
'presale:organizer.customer.activate' 'presale:organizer.customer.activate'
) + '?id=' + self.identifier + '&token=' + token ) + '?id=' + self.identifier + '&token=' + token
@@ -395,7 +395,7 @@ class AttendeeProfile(models.Model):
self.company, self.company,
self.street, self.street,
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''), (self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
self.country.name if self.country else None, self.country.name,
] ]
for a in self.answers: for a in self.answers:
value = a.get('value') value = a.get('value')
-27
View File
@@ -20,13 +20,11 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import string import string
from datetime import timedelta
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Max from django.db.models import Max
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
@@ -34,7 +32,6 @@ from pretix.base.models import LoggedModel
from pretix.base.permissions import ( from pretix.base.permissions import (
AnyPermissionOf, assert_valid_event_permission, AnyPermissionOf, assert_valid_event_permission,
) )
from pretix.helpers import BrinIndexIgnoredOnSQLite
@scopes_disabled() @scopes_disabled()
@@ -290,27 +287,3 @@ class Device(LoggedModel):
return self.get_events_with_any_permission() return self.get_events_with_any_permission()
else: else:
return self.organizer.events.none() return self.organizer.events.none()
class DeviceLastSeen(models.Model):
# This is a separate model since we expect it to get A LOT of writes and PostgreSQL always
# writes full rows and then needs to update all indexes on the row, so this is going to save a
# lot of write traffic on the databse
device = models.OneToOneField("Device", on_delete=models.CASCADE, related_name="last_seen")
last_seen = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
BrinIndexIgnoredOnSQLite(
# BRIN indexes are highly efficient on lots of updates, especially of chronological data
# and especially if we later want to query them by range, as we likely want to.
"last_seen",
name="pretixbase_device_last_seen",
autosummarize=True
)
]
@property
def is_recent(self):
# pretixSCAN/pretixPOS sync every 5 minutes, so 7 minutes can be considered "offline"
return now() - self.last_seen < timedelta(minutes=7)
+16 -43
View File
@@ -40,7 +40,6 @@ import warnings
from collections import Counter, OrderedDict, defaultdict from collections import Counter, OrderedDict, defaultdict
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from operator import attrgetter from operator import attrgetter
from typing import TYPE_CHECKING
from urllib.parse import urljoin from urllib.parse import urljoin
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -80,16 +79,10 @@ from pretix.helpers.thumb import get_thumbnail
from ..settings import settings_hierarkey from ..settings import settings_hierarkey
from .organizer import Organizer, Team from .organizer import Organizer, Team
if TYPE_CHECKING:
from hierarkey.proxy import HierarkeyProxy
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class EventMixin: class EventMixin:
if TYPE_CHECKING:
settings: HierarkeyProxy
def clean(self): def clean(self):
if self.presale_start and self.presale_end and self.presale_start > self.presale_end: if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')}) raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')})
@@ -179,12 +172,6 @@ class EventMixin:
self.date_to.astimezone(tz), ("D" if short else "l") self.date_to.astimezone(tz), ("D" if short else "l")
) )
def is_same_day(self):
if not self.date_to:
return True
else:
return self.date_from.astimezone(self.timezone).date() == self.date_to.astimezone(self.timezone).date()
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, try_to_show_times=False) -> str:
""" """
Returns a formatted string containing the start date and the end date Returns a formatted string containing the start date and the end date
@@ -238,9 +225,6 @@ class EventMixin:
@property @property
def timezone(self): def timezone(self):
# If we get rid of the shim, verify that
# https://github.com/py-vobject/vobject/issues/117#issuecomment-5045645314
# has been released and included
return pytz_deprecation_shim.timezone(self.settings.timezone) return pytz_deprecation_shim.timezone(self.settings.timezone)
@property @property
@@ -658,7 +642,7 @@ class Event(EventMixin, LoggedModel):
is_remote = models.BooleanField( is_remote = models.BooleanField(
default=False, default=False,
verbose_name=_("This event is remote or partially remote."), 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 to let us calculate the local time of a user."), 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( geo_lat = models.FloatField(
verbose_name=_("Latitude"), verbose_name=_("Latitude"),
@@ -740,7 +724,7 @@ class Event(EventMixin, LoggedModel):
@property @property
def social_image(self): def social_image(self):
from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.multidomain.urlreverse import build_absolute_uri
img = None img = None
logo_file = self.settings.get('logo_image', as_type=str, default='')[7:] logo_file = self.settings.get('logo_image', as_type=str, default='')[7:]
@@ -758,7 +742,7 @@ class Event(EventMixin, LoggedModel):
logger.exception(f'Failed to create thumbnail of {logo_file}') logger.exception(f'Failed to create thumbnail of {logo_file}')
img = default_storage.url(logo_file) img = default_storage.url(logo_file)
if img: if img:
return urljoin(eventreverse_absolute(self, 'presale:event.index'), img) return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
def _seats(self, ignore_voucher=None): def _seats(self, ignore_voucher=None):
from .seating import Seat from .seating import Seat
@@ -899,8 +883,6 @@ class Event(EventMixin, LoggedModel):
ItemProgramTime, ItemVariationMetaValue, Question, Quota, ItemProgramTime, ItemVariationMetaValue, Question, Quota,
) )
is_cross_organizer = other.organizer_id != self.organizer_id
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin. # Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
# Plugins can create data in installed() hook based on existing data of the event. # Plugins can create data in installed() hook based on existing data of the event.
# Calling set_active_plugins() results in defaults being created while actually data # Calling set_active_plugins() results in defaults being created while actually data
@@ -915,7 +897,7 @@ class Event(EventMixin, LoggedModel):
self.save() self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk}) self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
if hasattr(other, 'alternative_domain_assignment') and not is_cross_organizer: if hasattr(other, 'alternative_domain_assignment'):
other.alternative_domain_assignment.domain.event_assignments.create(event=self) other.alternative_domain_assignment.domain.event_assignments.create(event=self)
if not self.all_sales_channels: if not self.all_sales_channels:
@@ -929,15 +911,6 @@ class Event(EventMixin, LoggedModel):
for emv in EventMetaValue.objects.filter(event=other): for emv in EventMetaValue.objects.filter(event=other):
emv.pk = None emv.pk = None
emv.event = self emv.event = self
if is_cross_organizer:
try:
emv.property = self.organizer.meta_properties.get(name=emv.property.name)
except EventMetaProperty.DoesNotExist:
meta_prop = emv.property
meta_prop.pk = None
meta_prop.organizer = self.organizer
meta_prop.save(force_insert=True)
emv.property = meta_prop
emv.save(force_insert=True) emv.save(force_insert=True)
for fl in EventFooterLink.objects.filter(event=other): for fl in EventFooterLink.objects.filter(event=other):
@@ -991,13 +964,13 @@ class Event(EventMixin, LoggedModel):
if i.tax_rule_id: if i.tax_rule_id:
i.tax_rule = tax_map[i.tax_rule_id] i.tax_rule = tax_map[i.tax_rule_id]
if i.grant_membership_type and is_cross_organizer: if i.grant_membership_type and other.organizer_id != self.organizer_id:
i.grant_membership_type = None i.grant_membership_type = None
i.save() # no force_insert since i.picture.save could have already inserted i.save() # no force_insert since i.picture.save could have already inserted
i.log_action('pretix.object.cloned') i.log_action('pretix.object.cloned')
if require_membership_types and not is_cross_organizer: if require_membership_types and other.organizer_id == self.organizer_id:
i.require_membership_types.set(require_membership_types) i.require_membership_types.set(require_membership_types)
if not i.all_sales_channels: if not i.all_sales_channels:
@@ -1012,7 +985,7 @@ class Event(EventMixin, LoggedModel):
v._prefetched_objects_cache = {} v._prefetched_objects_cache = {}
v.save(force_insert=True) v.save(force_insert=True)
if require_membership_types and not is_cross_organizer: if require_membership_types and other.organizer_id == self.organizer_id:
v.require_membership_types.set(require_membership_types) v.require_membership_types.set(require_membership_types)
if not v.all_sales_channels: if not v.all_sales_channels:
v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels])) v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
@@ -1412,12 +1385,15 @@ class Event(EventMixin, LoggedModel):
for mp in self.organizer.meta_properties.all(): for mp in self.organizer.meta_properties.all():
if mp.required and not self.meta_data.get(mp.name): if mp.required and not self.meta_data.get(mp.name):
issues.append(format_html( issues.append(
'<a href="{href}{href_hash}">{text}</a>', ('<a {a_attr}>' + gettext('You need to fill the meta parameter "{property}".') + '</a>').format(
text=gettext('You need to fill the meta parameter "{property}".').format(property=mp.name), property=mp.name,
href=reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}), a_attr='href="%s#id_prop-%d-value"' % (
href_hash=f'#id_prop-{mp.pk}-value', reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
)) mp.pk
)
)
)
responses = event_live_issues.send(self) responses = event_live_issues.send(self)
for receiver, response in sorted(responses, key=lambda r: str(r[0])): for receiver, response in sorted(responses, key=lambda r: str(r[0])):
@@ -1860,7 +1836,6 @@ class EventMetaProperty(LoggedModel):
class Meta: class Meta:
ordering = ("position", "name",) ordering = ("position", "name",)
unique_together = ('organizer', 'name')
@property @property
def choice_keys(self): def choice_keys(self):
@@ -1894,8 +1869,6 @@ class EventMetaValue(LoggedModel):
self.event.cache.clear() self.event.cache.clear()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.event and self.event.organizer != self.property.organizer:
raise ValidationError(_("Property and event must belong to the same organizer."))
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.event: if self.event:
self.event.cache.clear() self.event.cache.clear()
+1 -2
View File
@@ -49,7 +49,6 @@ from django_scopes import ScopedManager
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.helpers.countries import FastCountryField from pretix.helpers.countries import FastCountryField
from pretix.helpers.models import NormalizedDecimalField
def invoice_filename(instance, filename: str) -> str: def invoice_filename(instance, filename: str) -> str:
@@ -451,7 +450,7 @@ class InvoiceLine(models.Model):
description = models.TextField() description = models.TextField()
gross_value = models.DecimalField(max_digits=13, decimal_places=2) gross_value = models.DecimalField(max_digits=13, decimal_places=2)
tax_value = models.DecimalField(max_digits=13, decimal_places=2, default=Decimal('0.00')) tax_value = models.DecimalField(max_digits=13, decimal_places=2, default=Decimal('0.00'))
tax_rate = NormalizedDecimalField(max_digits=7, decimal_places=4, default=Decimal('0')) tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
tax_name = models.CharField(max_length=190) tax_name = models.CharField(max_length=190)
tax_code = models.CharField(max_length=190, null=True, blank=True) tax_code = models.CharField(max_length=190, null=True, blank=True)
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT) subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
+42 -16
View File
@@ -452,16 +452,11 @@ class Item(LoggedModel):
MEDIA_POLICY_REUSE = 'reuse' MEDIA_POLICY_REUSE = 'reuse'
MEDIA_POLICY_NEW = 'new' MEDIA_POLICY_NEW = 'new'
MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new' MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new'
MEDIA_POLICY_APPEND = 'append'
MEDIA_POLICY_APPEND_OR_NEW = 'append_or_new'
MEDIA_POLICIES = ( MEDIA_POLICIES = (
(None, _("Don't use reusable media, use regular one-off tickets")), (None, _("Don't use re-usable media, use regular one-off tickets")),
(MEDIA_POLICY_REUSE, _('Require an existing medium to be re-used')),
(MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')), (MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')),
(MEDIA_POLICY_REUSE, _('Require an existing medium to be reused, replacing any previous tickets')), (MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used')),
(MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used, replacing any previous tickets')),
(MEDIA_POLICY_APPEND, _('Require an existing medium to be reused, adding to any previous tickets')),
(MEDIA_POLICY_APPEND_OR_NEW,
_('Require either an existing or a new medium to be used, adding to any previous tickets')),
) )
objects = ItemQuerySetManager() objects = ItemQuerySetManager()
@@ -774,7 +769,7 @@ class Item(LoggedModel):
null=True, blank=True, max_length=16, null=True, blank=True, max_length=16,
verbose_name=_('Reusable media policy'), verbose_name=_('Reusable media policy'),
help_text=_( help_text=_(
'If this product should be stored on a reusable physical medium, you can attach a physical media policy. ' 'If this product should be stored on a re-usable physical medium, you can attach a physical media policy. '
'This is not required for regular tickets, which just use a one-time barcode, but only for products like ' 'This is not required for regular tickets, which just use a one-time barcode, but only for products like '
'renewable season tickets or re-chargeable gift card wristbands. ' 'renewable season tickets or re-chargeable gift card wristbands. '
'This is an advanced feature that also requires specific configuration of ticketing and printing settings.' 'This is an advanced feature that also requires specific configuration of ticketing and printing settings.'
@@ -783,7 +778,7 @@ class Item(LoggedModel):
media_type = models.CharField( media_type = models.CharField(
max_length=100, max_length=100,
null=True, blank=True, null=True, blank=True,
choices=[(None, _("Don't use reusable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()], choices=[(None, _("Don't use re-usable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()],
verbose_name=_('Reusable media type'), verbose_name=_('Reusable media type'),
help_text=_( help_text=_(
'Select the type of physical medium that should be used for this product. Note that not all media types ' 'Select the type of physical medium that should be used for this product. Note that not all media types '
@@ -885,6 +880,26 @@ class Item(LoggedModel):
return False return False
return True return True
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
now_dt = now_dt or time_machine_now()
subevent_item = subevent and subevent.item_overrides.get(self.pk)
if not self.active:
return 'active'
elif self.available_from and self.available_from > now_dt:
return 'available_from'
elif self.available_until and self.available_until < now_dt:
return 'available_until'
elif (self.require_voucher or self.hide_without_voucher) and not has_voucher:
return 'require_voucher'
elif subevent_item and subevent_item.available_from and subevent_item.available_from > now_dt:
return 'available_from'
elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt:
return 'available_until'
elif self.hidden_if_item_available and self._dependency_available:
return 'hidden_if_item_available'
else:
return None
def _get_quotas(self, ignored_quotas=None, subevent=None): def _get_quotas(self, ignored_quotas=None, subevent=None):
check_quotas = set(getattr( check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list self, '_subevent_quotas', # Utilize cache in product list
@@ -980,11 +995,6 @@ class Item(LoggedModel):
raise ValidationError(_('The selected media type does not support usage for tickets currently.')) raise ValidationError(_('The selected media type does not support usage for tickets currently.'))
if not mt.supports_giftcard and issue_giftcard: if not mt.supports_giftcard and issue_giftcard:
raise ValidationError(_('The selected media type does not support usage for gift cards currently.')) raise ValidationError(_('The selected media type does not support usage for gift cards currently.'))
if media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
if not mt.medium_created_by_server and not mt.medium_created_from_unknown_supported:
raise ValidationError(_('The selected media type requires all media to be registered in the system '
'prior to their usage. Therefore, the selected media policy does not make '
'sense for this media type.'))
if issue_giftcard: if issue_giftcard:
raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, ' raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, '
'gift cards for some reusable media types can be created or re-charged directly ' 'gift cards for some reusable media types can be created or re-charged directly '
@@ -1393,6 +1403,22 @@ class ItemVariation(models.Model):
return False return False
return True return True
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
now_dt = now_dt or time_machine_now()
subevent_var = subevent and subevent.var_overrides.get(self.pk)
if not self.active:
return 'active'
elif self.available_from and self.available_from > now_dt:
return 'available_from'
elif self.available_until and self.available_until < now_dt:
return 'available_until'
elif subevent_var and subevent_var.available_from and subevent_var.available_from > now_dt:
return 'available_from'
elif subevent_var and subevent_var.available_until and subevent_var.available_until < now_dt:
return 'available_until'
else:
return None
@property @property
def meta_data(self): def meta_data(self):
data = self.item.meta_data data = self.item.meta_data
@@ -2194,7 +2220,7 @@ class Quota(LoggedModel):
class ItemMetaProperty(LoggedModel): class ItemMetaProperty(LoggedModel):
""" """
An event can have ItemMetaProperty objects attached to define meta information fields An event can have ItemMetaProperty objects attached to define meta information fields
for its items. This information can be reused for example in ticket layouts. for its items. This information can be re-used for example in ticket layouts.
:param event: The event this property is defined for. :param event: The event this property is defined for.
:type event: Event :type event: Event
+5 -20
View File
@@ -72,16 +72,6 @@ class ReusableMedium(LoggedModel):
max_length=200, max_length=200,
verbose_name=pgettext_lazy('reusable_medium', 'Identifier'), verbose_name=pgettext_lazy('reusable_medium', 'Identifier'),
) )
claim_token = models.CharField(
max_length=200,
verbose_name=pgettext_lazy('reusable_medium', 'Claim token'),
null=True, blank=True
)
label = models.CharField(
max_length=200,
verbose_name=pgettext_lazy('reusable_medium', 'Label'),
null=True, blank=True
)
active = models.BooleanField( active = models.BooleanField(
verbose_name=_('Active'), verbose_name=_('Active'),
@@ -99,14 +89,12 @@ class ReusableMedium(LoggedModel):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
verbose_name=_('Customer account'), verbose_name=_('Customer account'),
) )
linked_orderpositions = models.ManyToManyField( linked_orderposition = models.ForeignKey(
OrderPosition, OrderPosition,
null=True, blank=True,
related_name='linked_media', related_name='linked_media',
verbose_name=_('Linked tickets'), on_delete=models.SET_NULL,
help_text=_( verbose_name=_('Linked ticket'),
'If you link to more than one ticket, make sure there is no overlap in validity. '
'If multiple tickets are valid at once, this will lead to failed check-ins.'
)
) )
linked_giftcard = models.ForeignKey( linked_giftcard = models.ForeignKey(
GiftCard, GiftCard,
@@ -129,10 +117,7 @@ class ReusableMedium(LoggedModel):
@property @property
def is_expired(self): def is_expired(self):
return self.expires and self.expires < now() return self.expires and self.expires > now()
def touch(self):
self.save(update_fields=['updated'])
class Meta: class Meta:
unique_together = (("identifier", "type", "organizer"),) unique_together = (("identifier", "type", "organizer"),)
+53 -97
View File
@@ -87,7 +87,6 @@ from pretix.base.timemachine import time_machine_now
from ...helpers import OF_SELF from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.models import NormalizedDecimalField
from ...helpers.names import build_name from ...helpers.names import build_name
from ...testutils.middleware import debugflags_var from ...testutils.middleware import debugflags_var
from ._transactions import ( from ._transactions import (
@@ -225,6 +224,8 @@ class Order(LockModel, LoggedModel):
"Organizer", "Organizer",
related_name="orders", related_name="orders",
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True,
blank=True,
) )
event = models.ForeignKey( event = models.ForeignKey(
Event, Event,
@@ -328,7 +329,7 @@ class Order(LockModel, LoggedModel):
default="line", default="line",
) )
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='organizer') objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Order") verbose_name = _("Order")
@@ -353,60 +354,38 @@ class Order(LockModel, LoggedModel):
def _transaction_key_reset(self): def _transaction_key_reset(self):
self.__initial_status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval self.__initial_status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval
@classmethod
def gracefully_delete_bulk(cls, event, orders, user=None, auth=None):
# Expects to be called in a transaction
from . import (
GiftCard, GiftCardTransaction, LogEntry, Membership, Voucher,
)
if not transaction.get_connection().in_atomic_block:
raise Exception('gracefully_delete_bulk should only be called in atomic transaction!')
logs_create = []
for o in orders:
if not o.testmode:
raise TypeError("Only test mode orders can be deleted.")
order_gracefully_delete.send(event, order=o)
logs_create.append(o.log_action(
'pretix.event.order.deleted', user=user, auth=auth,
data={
'code': o.code,
},
save=False,
))
LogEntry.bulk_create_and_postprocess(logs_create)
voucher_ids = OrderPosition.objects.filter(
order__in=orders,
voucher__isnull=False
).exclude(order__status=Order.STATUS_CANCELED).values_list("voucher_id", flat=True)
voucher_usages = Counter(voucher_ids)
for v_id, usage_count in voucher_usages.items():
Voucher.objects.filter(pk=v_id).update(redeemed=Greatest(0, F('redeemed') - usage_count))
GiftCardTransaction.objects.filter(payment__order__in=orders).update(payment=None)
GiftCardTransaction.objects.filter(refund__order__in=orders).update(refund=None)
GiftCardTransaction.objects.filter(order__in=orders).update(order=None)
GiftCard.objects.filter(issued_in__order__in=orders).update(issued_in=None)
Membership.objects.filter(granted_in__order__in=orders, testmode=True).update(granted_in=None)
OrderPosition.all.filter(order__in=orders, addon_to__isnull=False).delete()
OrderPosition.all.filter(order__in=orders).delete()
OrderFee.all.filter(order__in=orders).delete()
Transaction.objects.filter(order__in=orders).delete()
OrderRefund.objects.filter(order__in=orders).delete()
OrderPayment.objects.filter(order__in=orders).delete()
if isinstance(orders, models.QuerySet):
orders.delete()
else:
Order.objects.filter(pk__in=[o.pk for o in orders]).delete()
event.cache.delete('complain_testmode_orders')
def gracefully_delete(self, user=None, auth=None): def gracefully_delete(self, user=None, auth=None):
from . import GiftCard, GiftCardTransaction, Membership, Voucher
if not self.testmode: if not self.testmode:
raise TypeError("Only test mode orders can be deleted.") raise TypeError("Only test mode orders can be deleted.")
self.log_action(
'pretix.event.order.deleted', user=user, auth=auth,
data={
'code': self.code,
}
)
Order.gracefully_delete_bulk(self.event, Order.objects.filter(pk=self.pk), user, auth) order_gracefully_delete.send(self.event, order=self)
if self.status != Order.STATUS_CANCELED:
for position in self.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
GiftCardTransaction.objects.filter(payment__in=self.payments.all()).update(payment=None)
GiftCardTransaction.objects.filter(refund__in=self.refunds.all()).update(refund=None)
GiftCardTransaction.objects.filter(order=self).update(order=None)
GiftCard.objects.filter(issued_in__in=self.positions.all()).update(issued_in=None)
Membership.objects.filter(granted_in__order=self, testmode=True).update(granted_in=None)
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
OrderPosition.all.filter(order=self).delete()
OrderFee.all.filter(order=self).delete()
Transaction.objects.filter(order=self).delete()
self.refunds.all().delete()
self.payments.all().delete()
self.event.cache.delete('complain_testmode_orders')
self.delete()
def email_confirm_secret(self): def email_confirm_secret(self):
return self.tagged_secret("email_confirm", 9) return self.tagged_secret("email_confirm", 9)
@@ -508,20 +487,20 @@ class Order(LockModel, LoggedModel):
@classmethod @classmethod
def annotate_overpayments(cls, qs, results=True, refunds=True, sums=False): def annotate_overpayments(cls, qs, results=True, refunds=True, sums=False):
payment_sum = OrderPayment.objects.with_scopes_disabled().filter( payment_sum = OrderPayment.objects.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
order=OuterRef('pk') order=OuterRef('pk')
).order_by().values('order').annotate(s=Sum('amount')).values('s') ).order_by().values('order').annotate(s=Sum('amount')).values('s')
refund_sum = OrderRefund.objects.with_scopes_disabled().filter( refund_sum = OrderRefund.objects.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED), OrderRefund.REFUND_STATE_CREATED),
order=OuterRef('pk') order=OuterRef('pk')
).order_by().values('order').annotate(s=Sum('amount')).values('s') ).order_by().values('order').annotate(s=Sum('amount')).values('s')
external_refund = OrderRefund.objects.with_scopes_disabled().filter( external_refund = OrderRefund.objects.filter(
state=OrderRefund.REFUND_STATE_EXTERNAL, state=OrderRefund.REFUND_STATE_EXTERNAL,
order=OuterRef('pk') order=OuterRef('pk')
) )
pending_refund = OrderRefund.objects.with_scopes_disabled().filter( pending_refund = OrderRefund.objects.filter(
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT), state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
order=OuterRef('pk') order=OuterRef('pk')
) )
@@ -1696,7 +1675,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
self.company, self.company,
self.street, self.street,
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''), (self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
self.country.name if self.country else '' self.country.name
] ]
lines = [r.strip() for r in lines if r] lines = [r.strip() for r in lines if r]
return '\n'.join(lines).strip() return '\n'.join(lines).strip()
@@ -2072,17 +2051,6 @@ class OrderPayment(models.Model):
""" """
return '{}-P-{}'.format(self.order.code, self.local_id) return '{}-P-{}'.format(self.order.code, self.local_id)
@property
def global_id(self):
"""
The global ID of this payment, constructed by the organizer slug, event slug, and the full id.
"""
return "{organizer}-{event}-{full_id}".format(
organizer=self.order.organizer.slug.upper(),
event=self.order.event.slug.upper(),
full_id=self.full_id,
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.local_id: if not self.local_id:
self.local_id = (self.order.payments.aggregate(m=Max('local_id'))['m'] or 0) + 1 self.local_id = (self.order.payments.aggregate(m=Max('local_id'))['m'] or 0) + 1
@@ -2283,17 +2251,6 @@ class OrderRefund(models.Model):
""" """
return '{}-R-{}'.format(self.order.code, self.local_id) return '{}-R-{}'.format(self.order.code, self.local_id)
@property
def global_id(self):
"""
The global ID of this refund, constructed by the organizer slug, event slug, and the full id.
"""
return "{organizer}-{event}-{full_id}".format(
organizer=self.order.organizer.slug.upper(),
event=self.order.event.slug.upper(),
full_id=self.full_id,
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.local_id: if not self.local_id:
self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1 self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1
@@ -2308,12 +2265,9 @@ class OrderRefund(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def ActivePositionManager(**scope): class ActivePositionManager(ScopedManager(organizer='order__event__organizer').__class__):
class InnerClass(ScopedManager(**scope).__class__): def get_queryset(self):
def get_queryset(self): return super().get_queryset().filter(canceled=False)
return super().get_queryset().filter(canceled=False)
return InnerClass()
class OrderFee(RoundingCorrectionMixin, models.Model): class OrderFee(RoundingCorrectionMixin, models.Model):
@@ -2380,8 +2334,8 @@ class OrderFee(RoundingCorrectionMixin, models.Model):
) )
description = models.CharField(max_length=190, blank=True) description = models.CharField(max_length=190, blank=True)
internal_type = models.CharField(max_length=255, blank=True) internal_type = models.CharField(max_length=255, blank=True)
tax_rate = NormalizedDecimalField( tax_rate = models.DecimalField(
max_digits=7, decimal_places=4, max_digits=7, decimal_places=2,
verbose_name=_('Tax rate') verbose_name=_('Tax rate')
) )
tax_rule = models.ForeignKey( tax_rule = models.ForeignKey(
@@ -2403,7 +2357,7 @@ class OrderFee(RoundingCorrectionMixin, models.Model):
canceled = models.BooleanField(default=False) canceled = models.BooleanField(default=False)
all = ScopedManager(organizer='order__event__organizer') all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager(organizer='order__event__organizer') objects = ActivePositionManager()
@property @property
def net_value(self): def net_value(self):
@@ -2565,6 +2519,8 @@ class OrderPosition(AbstractPosition):
"Organizer", "Organizer",
related_name="order_positions", related_name="order_positions",
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True,
blank=True,
) )
order = models.ForeignKey( order = models.ForeignKey(
Order, Order,
@@ -2577,8 +2533,8 @@ class OrderPosition(AbstractPosition):
max_digits=13, decimal_places=2, null=True, blank=True, max_digits=13, decimal_places=2, null=True, blank=True,
) )
tax_rate = NormalizedDecimalField( tax_rate = models.DecimalField(
max_digits=7, decimal_places=4, max_digits=7, decimal_places=2,
verbose_name=_('Tax rate') verbose_name=_('Tax rate')
) )
tax_rule = models.ForeignKey( tax_rule = models.ForeignKey(
@@ -2621,8 +2577,8 @@ class OrderPosition(AbstractPosition):
blank=True, blank=True,
) )
all = ScopedManager(organizer='organizer') all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager(organizer='organizer') objects = ActivePositionManager()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -3096,8 +3052,8 @@ class Transaction(models.Model):
price_includes_rounding_correction = models.DecimalField( price_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00") max_digits=13, decimal_places=2, default=Decimal("0.00")
) )
tax_rate = NormalizedDecimalField( tax_rate = models.DecimalField(
max_digits=7, decimal_places=4, max_digits=7, decimal_places=2,
verbose_name=_('Tax rate') verbose_name=_('Tax rate')
) )
tax_rule = models.ForeignKey( tax_rule = models.ForeignKey(
@@ -3212,8 +3168,8 @@ class CartPosition(AbstractPosition):
verbose_name=_("Limit for extending expiration date"), verbose_name=_("Limit for extending expiration date"),
null=True null=True
) )
tax_rate = NormalizedDecimalField( tax_rate = models.DecimalField(
max_digits=7, decimal_places=4, default=Decimal('0'), max_digits=7, decimal_places=2, default=Decimal('0.00'),
verbose_name=_('Tax rate') verbose_name=_('Tax rate')
) )
tax_code = models.CharField( tax_code = models.CharField(
@@ -3460,7 +3416,7 @@ class InvoiceAddress(models.Model):
self.name, self.name,
self.street, self.street,
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''), (self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
self.country.name if self.country else '', self.country.name,
self.vat_id, self.vat_id,
self.custom_field, self.custom_field,
self.internal_reference, self.internal_reference,
-7
View File
@@ -35,7 +35,6 @@ import operator
import string import string
from datetime import date, datetime, time from datetime import date, datetime, time
from functools import reduce from functools import reduce
from typing import TYPE_CHECKING
import pytz_deprecation_shim import pytz_deprecation_shim
from django.conf import settings from django.conf import settings
@@ -62,9 +61,6 @@ from ...helpers.permission_migration import (
from ..settings import settings_hierarkey from ..settings import settings_hierarkey
from .auth import User from .auth import User
if TYPE_CHECKING:
from hierarkey.proxy import HierarkeyProxy
@settings_hierarkey.add(cache_namespace='organizer') @settings_hierarkey.add(cache_namespace='organizer')
class Organizer(LoggedModel): class Organizer(LoggedModel):
@@ -82,9 +78,6 @@ class Organizer(LoggedModel):
""" """
settings_namespace = 'organizer' settings_namespace = 'organizer'
if TYPE_CHECKING:
settings: HierarkeyProxy
name = models.CharField(max_length=200, name = models.CharField(max_length=200,
verbose_name=_("Name")) verbose_name=_("Name"))
slug = models.CharField( slug = models.CharField(
+3 -6
View File
@@ -118,10 +118,7 @@ class SeatingPlan(LoggedModel):
for zi, z in enumerate(self.layout_data['zones']): for zi, z in enumerate(self.layout_data['zones']):
zpos = (z['position']['x'], z['position']['y']) zpos = (z['position']['x'], z['position']['y'])
for ri, r in enumerate(z['rows']): for ri, r in enumerate(z['rows']):
rpos = ( rpos = (zpos[0] + r['position']['x'], zpos[1] + r['position']['y'])
zpos[0] + r.get('position', {}).get('x', 0),
zpos[1] + r.get('position', {}).get('y', 0),
)
row_label = None row_label = None
if r.get('row_label'): if r.get('row_label'):
row_label = r['row_label'].replace("%s", r.get('row_number', str(ri))) row_label = r['row_label'].replace("%s", r.get('row_number', str(ri)))
@@ -150,8 +147,8 @@ class SeatingPlan(LoggedModel):
zone=z['name'], zone=z['name'],
category=s['category'], category=s['category'],
sorting_rank=rank, sorting_rank=rank,
x=rpos[0] + s.get('position', {}).get('x', 0), x=rpos[0] + s['position']['x'],
y=rpos[1] + s.get('position', {}).get('y', 0), y=rpos[1] + s['position']['y'],
) )
+3 -4
View File
@@ -40,7 +40,6 @@ from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.helpers.countries import FastCountryField from pretix.helpers.countries import FastCountryField
from pretix.helpers.models import NormalizedDecimalField
class TaxedPrice: class TaxedPrice:
@@ -336,9 +335,9 @@ class TaxRule(LoggedModel):
max_length=190, max_length=190,
choices=TAX_CODE_LISTS, choices=TAX_CODE_LISTS,
) )
rate = NormalizedDecimalField( rate = models.DecimalField(
max_digits=7, max_digits=10,
decimal_places=4, decimal_places=2,
validators=[ validators=[
MaxValueValidator( MaxValueValidator(
limit_value=Decimal("100.00"), limit_value=Decimal("100.00"),
+38 -45
View File
@@ -32,10 +32,8 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is # Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
import datetime
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from typing import Union
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -423,33 +421,27 @@ class Voucher(LoggedModel):
return False return False
@staticmethod @staticmethod
def get_affected_quotas(quota, item, variation, subevent): def clean_quota_get_ignored(old_instance):
if quota: quotas = set()
return {quota} was_valid = old_instance and (
elif item and variation: old_instance.valid_until is None or old_instance.valid_until >= now()
return set(variation.quotas.filter(subevent=subevent)) )
elif item and not item.has_variations: if old_instance and old_instance.block_quota and was_valid:
return set(item.quotas.filter(subevent=subevent)) if old_instance.quota:
elif item and item.has_variations: quotas.add(old_instance.quota)
return set( elif old_instance.variation:
Quota.objects.filter( quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
pk__in=Quota.variations.through.objects.filter( elif old_instance.item:
itemvariation__item=item, if old_instance.item.has_variations:
quota__subevent=subevent, quotas |= set(
).values('quota_id') Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
) itemvariation__item=old_instance.item,
) quota__subevent=old_instance.subevent,
else: ).values('quota_id'))
return set() )
else:
@staticmethod quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
def clean_quota_get_ignored(voucher_data: Union["VoucherBulkData", "Voucher"]): return quotas
if voucher_data:
valid = voucher_data.valid_until is None or voucher_data.valid_until >= now()
if valid and voucher_data.block_quota and voucher_data.max_usages > voucher_data.redeemed:
return Voucher.get_affected_quotas(voucher_data.quota, voucher_data.item, voucher_data.variation, voucher_data.subevent)
return set()
@staticmethod @staticmethod
def clean_quota_check(data, cnt, old_instance, event, quota, item, variation): def clean_quota_check(data, cnt, old_instance, event, quota, item, variation):
@@ -461,8 +453,22 @@ class Voucher(LoggedModel):
if event.has_subevents and data.get('block_quota') and not data.get('subevent'): if event.has_subevents and data.get('block_quota') and not data.get('subevent'):
raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.')) raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.'))
new_quotas = Voucher.get_affected_quotas(quota, item, variation, data.get('subevent')) if quota:
if not new_quotas: new_quotas = {quota}
elif item and variation:
new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
elif item and not item.has_variations:
new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
elif item and item.has_variations:
new_quotas = set(
Quota.objects.filter(
pk__in=Quota.variations.through.objects.filter(
itemvariation__item=item,
quota__subevent=data.get('subevent'),
).values('quota_id')
)
)
else:
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve ' raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
'tickets.')) 'tickets.'))
@@ -638,16 +644,3 @@ class Voucher(LoggedModel):
] ]
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00') ).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
return ops return ops
@dataclass
class VoucherBulkData:
item: object
variation: object
quota: object
block_quota: bool
valid_until: datetime.datetime
subevent: object
redeemed: int
max_usages: int
allow_ignore_quota: bool
+2 -2
View File
@@ -43,7 +43,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import Event, LogEntry from pretix.base.models import Event, LogEntry
from pretix.base.signals import register_notification_types from pretix.base.signals import register_notification_types
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.helpers.urls import mainreverse_absolute from pretix.helpers.urls import build_absolute_uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ALL_TYPES = None _ALL_TYPES = None
@@ -170,7 +170,7 @@ class ParametrizedOrderNotificationType(NotificationType):
def build_notification(self, logentry: LogEntry): def build_notification(self, logentry: LogEntry):
order = logentry.content_object order = logentry.content_object
order_url = mainreverse_absolute( order_url = build_absolute_uri(
'control:event.order', 'control:event.order',
kwargs={ kwargs={
'organizer': logentry.event.organizer.slug, 'organizer': logentry.event.organizer.slug,
+5 -57
View File
@@ -71,7 +71,7 @@ from pretix.helpers import OF_SELF
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map from pretix.helpers.format import format_map
from pretix.helpers.money import DecimalTextInput from pretix.helpers.money import DecimalTextInput
from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.views import get_cart from pretix.presale.views import get_cart
from pretix.presale.views.cart import cart_session, get_or_create_cart_id from pretix.presale.views.cart import cart_session, get_or_create_cart_id
@@ -379,7 +379,7 @@ class BasePaymentProvider:
if not self.settings.get('_hidden_seed'): if not self.settings.get('_hidden_seed'):
self.settings.set('_hidden_seed', get_random_string(64)) self.settings.set('_hidden_seed', get_random_string(64))
hidden_url = eventreverse_absolute(self.event, 'presale:event.payment.unlock', kwargs={ hidden_url = build_absolute_uri(self.event, 'presale:event.payment.unlock', kwargs={
'hash': hashlib.sha256((self.settings._hidden_seed + self.event.slug).encode()).hexdigest(), 'hash': hashlib.sha256((self.settings._hidden_seed + self.event.slug).encode()).hexdigest(),
}) })
@@ -834,7 +834,7 @@ class BasePaymentProvider:
""" """
raise NotImplementedError() # NOQA raise NotImplementedError() # NOQA
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str | None: def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
""" """
After the user has confirmed their purchase, this method will be called to complete After the user has confirmed their purchase, this method will be called to complete
the payment process. This is the place to actually move the money if applicable. the payment process. This is the place to actually move the money if applicable.
@@ -936,7 +936,7 @@ class BasePaymentProvider:
""" """
Will be called if the *event administrator* views the details of a payment. Will be called if the *event administrator* views the details of a payment.
It should return a SafeString containing HTML code, with information regarding the current payment It should return HTML code containing information regarding the current payment
status and, if applicable, next steps. status and, if applicable, next steps.
The default implementation returns an empty string. The default implementation returns an empty string.
@@ -961,7 +961,7 @@ class BasePaymentProvider:
""" """
Will be called if the *event administrator* views the details of a refund. Will be called if the *event administrator* views the details of a refund.
It should return a SafeString containing HTML code, with information regarding the current refund It should return HTML code containing information regarding the current refund
status and, if applicable, next steps. status and, if applicable, next steps.
The default implementation returns an empty string. The default implementation returns an empty string.
@@ -1706,58 +1706,6 @@ class GiftCardPayment(BasePaymentProvider):
) )
class BaseHistoricalPaymentProvider(BasePaymentProvider):
"""
Base class for payment providers that no longer exist but can't be deleted to make sure historical
payments are shown correctly.
Subclasses are recommended to only implement:
- identifier
- verbose_name
- public_name
- payment_control_render
- payment_control_render_short
- refund_control_render
- refund_control_render_short
- render_invoice_text
- render_invoice_stamp
- api_payment_details
- api_refund_details
- shred_payment_info
- matching_id
- refund_matching_id
"""
@property
def is_enabled(self) -> bool:
return False
@property
def settings_form_fields(self) -> dict:
return {}
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
return False
def payment_is_valid_session(self, request: HttpRequest, payment: OrderPayment):
return False
def order_change_allowed(self, order: Order, request: HttpRequest=None) -> bool:
return False
def payment_refund_supported(self, payment: OrderPayment) -> bool:
return False
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
return False
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
raise PaymentException(_("This payment provider exists for historical purposes only and is no longer usable."))
def execute_refund(self, refund: OrderRefund):
raise PaymentException(_("This payment provider exists for historical purposes only and is no longer usable."))
@receiver(register_payment_providers, dispatch_uid="payment_free") @receiver(register_payment_providers, dispatch_uid="payment_free")
def register_payment_provider(sender, **kwargs): def register_payment_provider(sender, **kwargs):
return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment, GiftCardPayment] return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment, GiftCardPayment]
+6 -16
View File
@@ -77,7 +77,6 @@ from reportlab.platypus import Paragraph
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Checkin, Event, Order, OrderPosition, Question from pretix.base.models import Checkin, Event, Order, OrderPosition, Question
from pretix.base.services.placeholders import PlaceholderContext
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
@@ -373,11 +372,6 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Atlantis"), "editor_sample": _("Atlantis"),
"evaluate": lambda op, order, ev: str(getattr(order.invoice_address.country, 'name', '')) if getattr(order, 'invoice_address', None) else '' "evaluate": lambda op, order, ev: str(getattr(order.invoice_address.country, 'name', '')) if getattr(order, 'invoice_address', None) else ''
}), }),
("invoice_custom_field", {
"label": _("Invoice custom recipient field"),
"editor_sample": _("Custom recipient field"),
"evaluate": lambda op, order, ev: order.invoice_address.custom_field if getattr(order, 'invoice_address', None) else ''
}),
("addons", { ("addons", {
"label": _("List of Add-Ons"), "label": _("List of Add-Ons"),
"editor_sample": _("Add-on 1\n2x Add-on 2"), "editor_sample": _("Add-on 1\n2x Add-on 2"),
@@ -402,7 +396,11 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Event organizer info text"), "editor_sample": _("Event organizer info text"),
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text) "evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
}), }),
("event_info_text", {}), # Placeholder to "reserve" position, defined later in `get_variables` ("event_info_text", {
"label": _("Event info text"),
"editor_sample": _("Event info text"),
"evaluate": lambda op, order, ev: str(order.event.settings.event_info_text)
}),
("now_date", { ("now_date", {
"label": _("Printing date"), "label": _("Printing date"),
"editor_sample": _("2017-05-31"), "editor_sample": _("2017-05-31"),
@@ -667,14 +665,6 @@ def get_images(event):
def get_variables(event): def get_variables(event):
v = copy.copy(DEFAULT_VARIABLES) v = copy.copy(DEFAULT_VARIABLES)
templating_context = PlaceholderContext(event=event)
v['event_info_text'] = {
"label": _("Event info text"),
"editor_sample": _("Event info text"),
"evaluate": lambda op, order, ev:
templating_context.format(str(order.event.settings.event_info_text))
}
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme] scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
concatenation_for_salutation = scheme.get("concatenation_for_salutation", scheme["concatenation"]) concatenation_for_salutation = scheme.get("concatenation_for_salutation", scheme["concatenation"])
@@ -1072,7 +1062,7 @@ class Renderer:
except: except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text))) logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
p = Paragraph(text, style=style) # not using AutoEscapeParagraph is safe as we escape above p = Paragraph(text, style=style)
return p, ad, lineheight return p, ad, lineheight
def _draw_textcontainer(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict): def _draw_textcontainer(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
-3
View File
@@ -245,9 +245,6 @@ def recv_classic(sender, **kwargs):
def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_used=False, force_invalidate=False, save=True): def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_used=False, force_invalidate=False, save=True):
if position.pk and position.issued_gift_cards.exists():
return
gen = event.ticket_secret_generator gen = event.ticket_secret_generator
if gen.use_revocation_list and force_invalidate_if_revokation_list_used: if gen.use_revocation_list and force_invalidate_if_revokation_list_used:
force_invalidate = True force_invalidate = True
+1 -3
View File
@@ -22,7 +22,6 @@
import logging import logging
from decimal import Decimal from decimal import Decimal
from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
@@ -378,13 +377,12 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
confirmation_code = get_random_string(8, allowed_chars="01234567890") confirmation_code = get_random_string(8, allowed_chars="01234567890")
mail( mail(
user.email, user.email,
subject=gettext('Confirm event cancellation and bulk refund'), subject=gettext('Bulk-refund confirmation'),
template='pretixbase/email/cancel_confirm.txt', template='pretixbase/email/cancel_confirm.txt',
context={ context={
"event": str(event), "event": str(event),
"amount": money_filter(refund_total, event.currency), "amount": money_filter(refund_total, event.currency),
"confirmation_code": confirmation_code, "confirmation_code": confirmation_code,
"instance": settings.PRETIX_INSTANCE_NAME,
}, },
locale=user.locale, locale=user.locale,
) )
+2 -7
View File
@@ -53,7 +53,6 @@ from django.utils.translation import (
) )
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
@@ -288,11 +287,11 @@ def _check_position_constraints(
raise CartPositionError(error_messages['unavailable']) raise CartPositionError(error_messages['unavailable'])
# Invalid media policy for online sale # Invalid media policy for online sale
if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
mt = MEDIA_TYPES[item.media_type] mt = MEDIA_TYPES[item.media_type]
if not mt.medium_created_by_server: if not mt.medium_created_by_server:
raise CartPositionError(error_messages['media_usage_not_implemented']) raise CartPositionError(error_messages['media_usage_not_implemented'])
elif item.media_policy in (Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_APPEND): elif item.media_policy == Item.MEDIA_POLICY_REUSE:
raise CartPositionError(error_messages['media_usage_not_implemented']) raise CartPositionError(error_messages['media_usage_not_implemented'])
# Item removed from sales channel # Item removed from sales channel
@@ -917,8 +916,6 @@ class CartManager:
if custom_price > 99_999_999_999: if custom_price > 99_999_999_999:
raise CartError(error_messages['price_too_high']) raise CartError(error_messages['price_too_high'])
custom_price = round_decimal(custom_price, currency=self.event.currency)
op = self.AddOperation( op = self.AddOperation(
count=i['count'], count=i['count'],
item=item, item=item,
@@ -1041,8 +1038,6 @@ class CartManager:
if custom_price > 99_999_999_999: if custom_price > 99_999_999_999:
raise CartError(error_messages['price_too_high']) raise CartError(error_messages['price_too_high'])
custom_price = round_decimal(custom_price, currency=self.event.currency)
# Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky) # Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky)
for ca in current_addons[cp][a['item'], a['variation']]: for ca in current_addons[cp][a['item'], a['variation']]:
if ca.listed_price != listed_price: if ca.listed_price != listed_price:
+5 -34
View File
@@ -40,7 +40,7 @@ import dateutil
import dateutil.parser import dateutil.parser
from dateutil.tz import datetime_exists from dateutil.tz import datetime_exists
from django.core.files import File from django.core.files import File
from django.db import IntegrityError from django.db import IntegrityError, transaction
from django.db.models import ( from django.db.models import (
BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min, BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min,
OuterRef, Q, Subquery, TextField, Value, When, OuterRef, Q, Subquery, TextField, Value, When,
@@ -59,7 +59,6 @@ from pretix.base.models import (
) )
from pretix.base.signals import checkin_created, periodic_task from pretix.base.signals import checkin_created, periodic_task
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.database import conditional_atomic
from pretix.helpers.jsonlogic import Logic from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
from pretix.helpers.jsonlogic_query import ( from pretix.helpers.jsonlogic_query import (
@@ -868,15 +867,6 @@ class RequiredQuestionsError(Exception):
super().__init__(msg) super().__init__(msg)
class RequiredMediaExchangeError(Exception):
def __init__(self, msg, code, media_policy, media_type):
self.msg = msg
self.code = code
self.media_policy = media_policy
self.media_type = media_type
super().__init__(msg)
def _save_answers(op, answers, given_answers): def _save_answers(op, answers, given_answers):
def _create_answer(question, answer): def _create_answer(question, answer):
try: try:
@@ -949,7 +939,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY, user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False, raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False,
gate=None, reusable_medium=None): gate=None):
""" """
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
not valid at this time. not valid at this time.
@@ -965,7 +955,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
:param datetime: The datetime of the checkin, defaults to now. :param datetime: The datetime of the checkin, defaults to now.
:param simulate: If true, the check-in is not saved. :param simulate: If true, the check-in is not saved.
:param gate: The gate the check-in was performed at. :param gate: The gate the check-in was performed at.
:param reusable_medium: The medium that is available for an exchange
""" """
# !!!!!!!!! # !!!!!!!!!
@@ -1044,10 +1033,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
if not simulate: if not simulate:
_save_answers(op, answers, given_answers) _save_answers(op, answers, given_answers)
with conditional_atomic(not simulate): with transaction.atomic():
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic # Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
opqs = OrderPosition.all.select_related("order", "item") opqs = OrderPosition.all
if type != Checkin.TYPE_EXIT and not simulate: if type != Checkin.TYPE_EXIT:
opqs = opqs.select_for_update(of=OF_SELF) opqs = opqs.select_for_update(of=OF_SELF)
op = opqs.get(pk=op.pk) op = opqs.get(pk=op.pk)
@@ -1112,24 +1101,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
require_answers require_answers
) )
required_media_policy = op.item.media_policy
required_media_type = op.item.media_type
require_a_medium = required_media_policy and required_media_type
linked_media = op.linked_media
if require_a_medium and not reusable_medium and not force:
if not linked_media.exists():
raise RequiredMediaExchangeError(
_('Ticket needs to be exchanged to a suitable medium.'),
'exchange',
required_media_policy,
required_media_type
)
elif op.organizer.settings.reusable_media_usage_enforced:
raise CheckInError(
_('This ticket has already been exchanged for a reusable medium that now needs to be used instead.'),
'already_exchanged',
)
device = None device = None
if isinstance(auth, Device): if isinstance(auth, Device):
device = auth device = auth
+2 -2
View File
@@ -29,7 +29,7 @@ from typing import List
from django.utils.functional import cached_property from django.utils.functional import cached_property
from pretix.base.models import CartPosition, ItemCategory, SalesChannel from pretix.base.models import CartPosition, ItemCategory, SalesChannel
from pretix.presale.productlist import prepare_item_list_for_shop from pretix.presale.views.event import get_grouped_items
class DummyCategory: class DummyCategory:
@@ -162,7 +162,7 @@ class CrossSellingService:
] ]
def _prepare_items(self, subevent, items_qs, discount_info): def _prepare_items(self, subevent, items_qs, discount_info):
items, _btn = prepare_item_list_for_shop( items, _btn = get_grouped_items(
self.event, self.event,
subevent=subevent, subevent=subevent,
voucher=None, voucher=None,
+4 -5
View File
@@ -51,7 +51,7 @@ from pretix.base.signals import (
) )
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers import OF_SELF, repeatable_reads_transaction from pretix.helpers import OF_SELF, repeatable_reads_transaction
from pretix.helpers.urls import mainreverse_absolute from pretix.helpers.urls import build_absolute_uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -340,13 +340,12 @@ def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter,
if schedule.owner.is_active: if schedule.owner.is_active:
mail( mail(
email=schedule.owner.email, email=schedule.owner.email,
subject=gettext('Scheduled export failed'), subject=gettext('Export failed'),
template='pretixbase/email/export_failed.txt', template='pretixbase/email/export_failed.txt',
context={ context={
'configuration_url': config_url, 'configuration_url': config_url,
'reason': msg, 'reason': msg,
'soft': soft, 'soft': soft,
'instance': settings.PRETIX_INSTANCE_NAME,
}, },
event=context if isinstance(context, Event) else None, event=context if isinstance(context, Event) else None,
organizer=context.organizer if isinstance(context, Event) else context, organizer=context.organizer if isinstance(context, Event) else context,
@@ -456,7 +455,7 @@ def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> Non
schedule, schedule,
organizer, organizer,
exporter, exporter,
mainreverse_absolute( build_absolute_uri(
'control:organizer.export', 'control:organizer.export',
kwargs={ kwargs={
'organizer': organizer.slug, 'organizer': organizer.slug,
@@ -482,7 +481,7 @@ def scheduled_event_export(self, event: Event, schedule: int) -> None:
schedule, schedule,
event, event,
exporter, exporter,
mainreverse_absolute( build_absolute_uri(
'control:event.orders.export', 'control:event.orders.export',
kwargs={ kwargs={
'event': event.slug, 'event': event.slug,
+3 -3
View File
@@ -85,7 +85,7 @@ from pretix.helpers.format import (
FormattedString, PlainHtmlAlternativeString, SafeFormatter, format_map, FormattedString, PlainHtmlAlternativeString, SafeFormatter, format_map,
) )
from pretix.helpers.hierarkey import clean_filename from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_private_icals from pretix.presale.ical import get_private_icals
logger = logging.getLogger('pretix.base.mail') logger = logging.getLogger('pretix.base.mail')
@@ -997,7 +997,7 @@ def _wrap_plain_body(content_plain, signature, event, order, position, no_order_
body_plain += _( body_plain += _(
"You can view your order details at the following URL:\n{orderurl}." "You can view your order details at the following URL:\n{orderurl}."
).replace("\n", "\r\n").format( ).replace("\n", "\r\n").format(
orderurl=eventreverse_absolute( orderurl=build_absolute_uri(
order.event, 'presale:event.order.position', kwargs={ order.event, 'presale:event.order.position', kwargs={
'order': order.code, 'order': order.code,
'secret': position.web_secret, 'secret': position.web_secret,
@@ -1013,7 +1013,7 @@ def _wrap_plain_body(content_plain, signature, event, order, position, no_order_
body_plain += _( body_plain += _(
"You can view your order details at the following URL:\n{orderurl}." "You can view your order details at the following URL:\n{orderurl}."
).replace("\n", "\r\n").format( ).replace("\n", "\r\n").format(
event=event.name, orderurl=eventreverse_absolute( event=event.name, orderurl=build_absolute_uri(
order.event, 'presale:event.order.open', kwargs={ order.event, 'presale:event.order.open', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret,
+2 -176
View File
@@ -23,13 +23,10 @@ import secrets
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.media import MEDIA_TYPES from pretix.base.models import GiftCardAcceptance
from pretix.base.models import Checkin, GiftCardAcceptance, Item from pretix.base.models.media import MediumKeySet
from pretix.base.models.media import MediumKeySet, ReusableMedium
from pretix.base.services.checkin import CheckInError
def create_nfc_mf0aes_keyset(organizer): def create_nfc_mf0aes_keyset(organizer):
@@ -73,174 +70,3 @@ def get_keysets_for_organizer(organizer):
if new_set: if new_set:
sets.append(new_set) sets.append(new_set)
return sets return sets
def perform_media_exchange(organizer, media_type, identifier, link_orderposition, user, auth):
"""
Create or retrieve a medium, then link the order position to it. Expected to be called in a transaction.
:param organizer: Organizer to operate in
:param media_type: Type of medium to operate with
:param identifier: Identifier of the medium
:param link_orderposition: Position to link to the medium
:return: ReusableMedium
"""
medium = None
media_policy = link_orderposition.item.media_policy
if media_type not in MEDIA_TYPES: # should be caught by serializer already
raise CheckInError(
_('Invalid medium type.'),
Checkin.REASON_ERROR,
reason=_('Invalid medium type.'),
)
if not MEDIA_TYPES[media_type].is_active(organizer):
raise CheckInError(
_('Medium type is not enabled for organizer.'),
Checkin.REASON_ERROR,
reason=_('Medium type is not enabled for organizer.'),
)
if link_orderposition.item.media_type != media_type:
raise CheckInError(
_('Incorrect medium type for product.'),
Checkin.REASON_PRODUCT,
reason=_('Incorrect medium type for product.'),
)
if link_orderposition.linked_media.exists():
raise CheckInError(
_('Ticket is already exchanged for reusable medium.'),
Checkin.REASON_ALREADY_EXCHANGED,
reason=_('Ticket is already exchanged for reusable medium.'),
)
if media_policy in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_NEW):
link_action = "append"
else:
link_action = "replace"
if media_policy in (Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_APPEND):
try:
medium = ReusableMedium.objects.get(
type=media_type,
identifier=identifier,
organizer=organizer,
)
except ReusableMedium.DoesNotExist:
raise CheckInError(
_('Reusable medium not found.'),
Checkin.REASON_MEDIUM_INVALID,
reason=_('Reusable medium not found.'),
)
else:
if medium.is_expired or not medium.active:
raise CheckInError(
_('Reusable medium is inactive or expired.'),
Checkin.REASON_MEDIUM_INVALID,
reason=_('Reusable medium is inactive or expired.'),
)
elif media_policy in (Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW):
try:
medium = ReusableMedium.objects.get(
type=media_type,
identifier=identifier,
organizer=organizer,
)
except ReusableMedium.DoesNotExist:
if not MEDIA_TYPES[media_type].medium_created_from_unknown_supported:
raise CheckInError(
_('Reusable medium not found and could not be created.'),
Checkin.REASON_MEDIUM_INVALID,
)
medium = MEDIA_TYPES[media_type].handle_unknown(organizer, identifier, user, auth, force_create=True)
if not medium:
raise CheckInError(
_('Reusable medium not found and could not be created.'),
Checkin.REASON_MEDIUM_INVALID,
)
if medium.is_expired or not medium.active:
raise CheckInError(
_('Reusable medium is inactive or expired.'),
Checkin.REASON_MEDIUM_INVALID,
reason=_('Reusable medium is inactive or expired.'),
)
elif media_policy == Item.MEDIA_POLICY_NEW:
if not MEDIA_TYPES[media_type].medium_created_from_unknown_supported:
raise CheckInError(
_('Reusable medium not found and could not be created.'),
Checkin.REASON_MEDIUM_INVALID,
)
try:
medium = MEDIA_TYPES[media_type].handle_unknown(organizer, identifier, user, auth, force_create=True)
except IntegrityError:
raise CheckInError(
_('Reusable medium already exists.'),
Checkin.REASON_MEDIUM_EXISTS,
)
else:
if not medium:
raise CheckInError(
_('Reusable medium could not be created.'),
Checkin.REASON_MEDIUM_INVALID,
)
else:
raise CheckInError(
_('Product does not support medium exchange.'),
Checkin.REASON_PRODUCT,
reason=_('Product does not support medium exchange.'),
)
if link_action == 'append':
medium.linked_orderpositions.add(link_orderposition)
medium.log_action(
'pretix.reusable_medium.linked_orderposition.added',
user=user,
auth=auth,
data={
'linked_orderposition': link_orderposition,
}
)
elif link_action == 'replace':
already_found = False
for op_pk in medium.linked_orderpositions.values_list('pk', flat=True):
if op_pk == link_orderposition.pk:
already_found = True
continue
else:
medium.log_action(
'pretix.reusable_medium.linked_orderposition.removed',
data={
'linked_orderposition': op_pk,
}
)
if not already_found:
medium.linked_orderpositions.set([link_orderposition])
medium.log_action(
'pretix.reusable_medium.linked_orderposition.added',
user=user,
auth=auth,
data={
'linked_orderposition': link_orderposition,
}
)
link_orderposition.order.log_action(
'pretix.reusable_medium.exchanged',
data={
'position': link_orderposition.pk,
'positionid': link_orderposition.positionid,
'medium': medium.pk,
'medium_identifier': medium.identifier,
'medium_type': medium.media_type.identifier,
}
)
medium.touch()
return medium
-3
View File
@@ -24,7 +24,6 @@ from typing import List, Optional
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -97,8 +96,6 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
:param valid_from_not_chosen: Set to ``True`` to indicate that the customer is in an early step of the checkout flow :param valid_from_not_chosen: Set to ``True`` to indicate that the customer is in an early step of the checkout flow
where the valid_from date is not selected yet. In this case, the valid_from date is not checked. where the valid_from date is not selected yet. In this case, the valid_from date is not checked.
""" """
if lock and not transaction.get_connection().in_atomic_block:
raise Exception('validate_memberships_in_order(lock=True) should only be called in atomic transaction!')
tz = event.timezone tz = event.timezone
applicable_positions = [ applicable_positions = [
p for p in positions p for p in positions
+3 -33
View File
@@ -26,9 +26,8 @@ from typing import List
from django.conf import settings as django_settings from django.conf import settings as django_settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.db.utils import IntegrityError
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext as _, ngettext from django.utils.translation import gettext as _
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.modelimport import DataImportError, ImportColumn, parse_csv from pretix.base.modelimport import DataImportError, ImportColumn, parse_csv
@@ -261,7 +260,6 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction # Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
# shorter. We'll see what works better in reality… # shorter. We'll see what works better in reality…
vouchers = [] vouchers = []
codes = set()
lock_seats = [] lock_seats = []
for i, record in enumerate(data): for i, record in enumerate(data):
try: try:
@@ -270,14 +268,6 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
if not record.get("code"): if not record.get("code"):
raise ValidationError(_('A voucher cannot be created without a code.')) raise ValidationError(_('A voucher cannot be created without a code.'))
code = record.get("code")
if code.upper() in codes:
raise ValidationError(
_('Voucher codes must be unique. Code "{code}" already exists in this import.').format(
code=code,
)
)
codes.add(code.upper())
Voucher.clean_item_properties( Voucher.clean_item_properties(
record, record,
event, event,
@@ -296,22 +286,8 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
lock_seats.append(voucher.seat) lock_seats.append(voucher.seat)
except (ValidationError, ImportError) as e: except (ValidationError, ImportError) as e:
raise DataImportError( 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))
) )
existing_codes = Voucher.objects.filter(
event=event,
code__in=codes,
).values_list("code", flat=True)
if len(existing_codes):
raise DataImportError(
ngettext(
'Voucher codes must be unique. Import contains existing voucher code {code}.',
'Voucher codes must be unique. Import contains existing voucher codes {code}.',
len(existing_codes)
).format(
code=", ".join(existing_codes)
)
)
with transaction.atomic(): with transaction.atomic():
# We don't support quotas here, so we only need to lock if seats are in use # We don't support quotas here, so we only need to lock if seats are in use
@@ -324,13 +300,7 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
save_logentries = [] save_logentries = []
for v in vouchers: for v in vouchers:
try: v.save()
v.save()
except IntegrityError:
# should not happen as we check existing codes before, but we did not lock so we might have a race-condition
raise DataImportError(
_('Vouchers could not be imported, probably due to a voucher code already being in use.')
)
save_logentries.append(v.log_action( save_logentries.append(v.log_action(
'pretix.voucher.added', 'pretix.voucher.added',
user=user, user=user,
+3 -3
View File
@@ -37,7 +37,7 @@ from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
from pretix.base.signals import notification from pretix.base.signals import notification
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.celery import get_task_priority from pretix.helpers.celery import get_task_priority
from pretix.helpers.urls import mainreverse_absolute from pretix.helpers.urls import build_absolute_uri
@app.task(base=TransactionAwareTask, acks_late=True, max_retries=9, default_retry_delay=900) @app.task(base=TransactionAwareTask, acks_late=True, max_retries=9, default_retry_delay=900)
@@ -136,10 +136,10 @@ def send_notification_mail(notification: Notification, user: User):
'site_url': settings.SITE_URL, 'site_url': settings.SITE_URL,
'color': settings.PRETIX_PRIMARY_COLOR, 'color': settings.PRETIX_PRIMARY_COLOR,
'notification': notification, 'notification': notification,
'settings_url': mainreverse_absolute( 'settings_url': build_absolute_uri(
'control:user.settings.notifications', 'control:user.settings.notifications',
), ),
'disable_url': mainreverse_absolute( 'disable_url': build_absolute_uri(
'control:user.settings.notifications.off', 'control:user.settings.notifications.off',
kwargs={ kwargs={
'token': user.notifications_token, 'token': user.notifications_token,
+25 -54
View File
@@ -110,7 +110,6 @@ from pretix.celery_app import app
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
from pretix.helpers.periodic import minimum_interval from pretix.helpers.periodic import minimum_interval
from pretix.presale.productlist import prepare_item_list_for_shop
from pretix.testutils.middleware import debugflags_var from pretix.testutils.middleware import debugflags_var
@@ -791,12 +790,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
[op.seat for op in sorted_positions if op.seat], [op.seat for op in sorted_positions if op.seat],
shared_lock_objects=[event] shared_lock_objects=[event]
) )
elif any(cp.voucher and cp.voucher.budget for cp in sorted_positions):
# Voucher budgets are not guaranteed by the cart manager
lock_objects(
[op.voucher for op in sorted_positions if op.voucher and op.voucher.budget],
shared_lock_objects=[event]
)
q_avail = Counter() q_avail = Counter()
v_avail = Counter() v_avail = Counter()
@@ -1606,7 +1599,6 @@ class OrderChangeManager:
'seat_forbidden': gettext_lazy('The selected product does not allow to select a seat.'), 'seat_forbidden': gettext_lazy('The selected product does not allow to select a seat.'),
'tax_rule_country_blocked': gettext_lazy('The selected country is blocked by your tax rule.'), 'tax_rule_country_blocked': gettext_lazy('The selected country is blocked by your tax rule.'),
'gift_card_change': gettext_lazy('You cannot change the price of a position that has been used to issue a gift card.'), 'gift_card_change': gettext_lazy('You cannot change the price of a position that has been used to issue a gift card.'),
'gift_card_secret': gettext_lazy('You cannot change the ticket secret of a position that has been used to issue a gift card.'),
'max_items_per_product': ngettext_lazy( 'max_items_per_product': ngettext_lazy(
"You cannot select more than %(max)s item of the product %(product)s.", "You cannot select more than %(max)s item of the product %(product)s.",
"You cannot select more than %(max)s items of the product %(product)s.", "You cannot select more than %(max)s items of the product %(product)s.",
@@ -1764,9 +1756,6 @@ class OrderChangeManager:
self._operations.append(self.RegenerateSecretOperation(position)) self._operations.append(self.RegenerateSecretOperation(position))
def change_ticket_secret(self, position: OrderPosition, new_secret: str): def change_ticket_secret(self, position: OrderPosition, new_secret: str):
if position.issued_gift_cards.exists():
raise OrderError(self.error_messages['gift_card_secret'])
self._operations.append(self.ChangeSecretOperation(position, new_secret)) self._operations.append(self.ChangeSecretOperation(position, new_secret))
def change_valid_from(self, position: OrderPosition, new_value: datetime): def change_valid_from(self, position: OrderPosition, new_value: datetime):
@@ -1954,18 +1943,13 @@ class OrderChangeManager:
:param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values), :param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values),
``"count"``, and ``"price"``. ``"count"``, and ``"price"``.
:param limit_main_positions: By default, the method works on all positions of the order. If you set this to a :param limit_main_positions: By default, the method works on all methods of the order. If you set this to a
queryset or a list of positions, all other positions and their add-ons will be kept queryset or a list of positions, all other positions and their add-ons will be kept
untouched. untouched.
""" """
if self._operations: if self._operations:
raise ValueError("Setting addons should be the first/only operation") raise ValueError("Setting addons should be the first/only operation")
def _allowed_on_order_sales_channel(item_or_var, order):
return item_or_var.all_sales_channels or (
order.sales_channel.identifier in (s.identifier for s in item_or_var.limit_sales_channels.all())
)
# Prepare containers for min/max check of products # Prepare containers for min/max check of products
item_counts = Counter() item_counts = Counter()
for p in self.order.positions.all(): for p in self.order.positions.all():
@@ -2059,11 +2043,13 @@ class OrderChangeManager:
if not item.is_available() or (variation and not variation.is_available()): if not item.is_available() or (variation and not variation.is_available()):
raise OrderError(error_messages['unavailable']) raise OrderError(error_messages['unavailable'])
if not _allowed_on_order_sales_channel(item, self.order): if not item.all_sales_channels:
raise OrderError(error_messages['unavailable']) if self.order.sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()):
raise OrderError(error_messages['unavailable'])
if variation and not _allowed_on_order_sales_channel(variation, self.order): if variation and not variation.all_sales_channels:
raise OrderError(error_messages['unavailable']) if self.order.sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()):
raise OrderError(error_messages['unavailable'])
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available(): if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available():
raise OrderError(error_messages['not_for_sale']) raise OrderError(error_messages['not_for_sale'])
@@ -2111,36 +2097,6 @@ class OrderChangeManager:
) )
item_counts[item] += 1 item_counts[item] += 1
def _addon_is_available(a):
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always set voucher=None because that's what's done when generating the form in
# OrderChangeMixin (vouchers for addons are not supported).
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
if not _allowed_on_order_sales_channel(a.item, self.order) or (
a.variation and not _allowed_on_order_sales_channel(a.variation, self.order)
):
return False
items, _ = prepare_item_list_for_shop(
self.order.event,
channel=self.order.sales_channel,
subevent=a.subevent,
voucher=None,
base_qs=Item.objects.filter(pk=a.item.pk),
allow_addons=True
)
if (not items) or items[0].current_unavailability_reason:
return False
if a.variation:
variations = [var for var in items[0].available_variations if var.pk == a.variation.pk]
if (not variations) or variations[0].current_unavailability_reason:
return False
return True
# Detect removed add-ons and create RemoveOperations # Detect removed add-ons and create RemoveOperations
for cp, al in list(current_addons.items()): for cp, al in list(current_addons.items()):
for k, v in al.items(): for k, v in al.items():
@@ -2150,7 +2106,22 @@ class OrderChangeManager:
for a in current_addons[cp][k][:current_num - input_num]: for a in current_addons[cp][k][:current_num - input_num]:
if a.canceled: if a.canceled:
continue continue
if not _addon_is_available(a): is_unavailable = (
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
# not mean it should be unremovable for others.
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or (
not a.item.all_sales_channels and
not a.item.limit_sales_channels.contains(self.order.sales_channel)
)
)
if is_unavailable:
# "Re-select" add-on # "Re-select" add-on
selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1 selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1
continue continue
@@ -3535,7 +3506,7 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
from pretix.base.models import ReusableMedium from pretix.base.models import ReusableMedium
for p in order.positions.all(): for p in order.positions.all():
if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW): if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
mt = MEDIA_TYPES[p.item.media_type] mt = MEDIA_TYPES[p.item.media_type]
if mt.medium_created_by_server and not p.linked_media.exists(): if mt.medium_created_by_server and not p.linked_media.exists():
rm = ReusableMedium.objects.create( rm = ReusableMedium.objects.create(
@@ -3544,8 +3515,8 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
identifier=mt.generate_identifier(sender.organizer), identifier=mt.generate_identifier(sender.organizer),
active=True, active=True,
customer=order.customer, customer=order.customer,
linked_orderposition=p,
) )
rm.linked_orderpositions.add(p)
rm.log_action( rm.log_action(
'pretix.reusable_medium.created', 'pretix.reusable_medium.created',
data={ data={
+32 -40
View File
@@ -327,7 +327,7 @@ def get_best_name(position_or_address, parts=False):
@receiver(register_text_placeholders, dispatch_uid="pretixbase_register_text_placeholders") @receiver(register_text_placeholders, dispatch_uid="pretixbase_register_text_placeholders")
def base_placeholders(sender, **kwargs): def base_placeholders(sender, **kwargs):
from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.multidomain.urlreverse import build_absolute_uri
def _event_sample(event): def _event_sample(event):
if event.has_subevents: if event.has_subevents:
@@ -388,14 +388,14 @@ def base_placeholders(sender, **kwargs):
lambda event: LazyDate(now() + timedelta(days=15)) lambda event: LazyDate(now() + timedelta(days=15))
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url', ['order', 'event'], lambda order, event: eventreverse_absolute( 'url', ['order', 'event'], lambda order, event: build_absolute_uri(
event, event,
'presale:event.order.open', kwargs={ 'presale:event.order.open', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret,
'hash': order.email_confirm_secret() 'hash': order.email_confirm_secret()
} }
), lambda event: eventreverse_absolute( ), lambda event: build_absolute_uri(
event, event,
'presale:event.order.open', kwargs={ 'presale:event.order.open', kwargs={
'order': 'F8VVL', 'order': 'F8VVL',
@@ -406,7 +406,7 @@ def base_placeholders(sender, **kwargs):
), ),
SimpleButtonPlaceholder( SimpleButtonPlaceholder(
'url_button', ['order', 'event'], 'url_button', ['order', 'event'],
url_func=lambda order, event: eventreverse_absolute( url_func=lambda order, event: build_absolute_uri(
event, event,
'presale:event.order.open', kwargs={ 'presale:event.order.open', kwargs={
'order': order.code, 'order': order.code,
@@ -415,7 +415,7 @@ def base_placeholders(sender, **kwargs):
} }
), ),
text_func=lambda order, event: _("View order details"), text_func=lambda order, event: _("View order details"),
sample_url_func=lambda event: eventreverse_absolute( sample_url_func=lambda event: build_absolute_uri(
event, event,
'presale:event.order.open', kwargs={ 'presale:event.order.open', kwargs={
'order': 'F8VVL', 'order': 'F8VVL',
@@ -426,13 +426,13 @@ def base_placeholders(sender, **kwargs):
sample_text_func=lambda event: _("View order details"), sample_text_func=lambda event: _("View order details"),
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url_info_change', ['order', 'event'], lambda order, event: eventreverse_absolute( 'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event, event,
'presale:event.order.modify', kwargs={ 'presale:event.order.modify', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret,
} }
), lambda event: eventreverse_absolute( ), lambda event: build_absolute_uri(
event, event,
'presale:event.order.modify', kwargs={ 'presale:event.order.modify', kwargs={
'order': 'F8VVL', 'order': 'F8VVL',
@@ -441,13 +441,13 @@ def base_placeholders(sender, **kwargs):
), ),
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url_products_change', ['order', 'event'], lambda order, event: eventreverse_absolute( 'url_products_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event, event,
'presale:event.order.change', kwargs={ 'presale:event.order.change', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret,
} }
), lambda event: eventreverse_absolute( ), lambda event: build_absolute_uri(
event, event,
'presale:event.order.change', kwargs={ 'presale:event.order.change', kwargs={
'order': 'F8VVL', 'order': 'F8VVL',
@@ -456,13 +456,13 @@ def base_placeholders(sender, **kwargs):
), ),
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url_cancel', ['order', 'event'], lambda order, event: eventreverse_absolute( 'url_cancel', ['order', 'event'], lambda order, event: build_absolute_uri(
event, event,
'presale:event.order.cancel', kwargs={ 'presale:event.order.cancel', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret,
} }
), lambda event: eventreverse_absolute( ), lambda event: build_absolute_uri(
event, event,
'presale:event.order.cancel', kwargs={ 'presale:event.order.cancel', kwargs={
'order': 'F8VVL', 'order': 'F8VVL',
@@ -471,7 +471,7 @@ def base_placeholders(sender, **kwargs):
), ),
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url', ['event', 'position'], lambda event, position: eventreverse_absolute( 'url', ['event', 'position'], lambda event, position: build_absolute_uri(
event, event,
'presale:event.order.position', 'presale:event.order.position',
kwargs={ kwargs={
@@ -480,7 +480,7 @@ def base_placeholders(sender, **kwargs):
'position': position.positionid 'position': position.positionid
} }
), ),
lambda event: eventreverse_absolute( lambda event: build_absolute_uri(
event, event,
'presale:event.order.position', kwargs={ 'presale:event.order.position', kwargs={
'order': 'F8VVL', 'order': 'F8VVL',
@@ -491,7 +491,7 @@ def base_placeholders(sender, **kwargs):
), ),
SimpleButtonPlaceholder( SimpleButtonPlaceholder(
'url_button', ['event', 'position'], 'url_button', ['event', 'position'],
url_func=lambda event, position: eventreverse_absolute( url_func=lambda event, position: build_absolute_uri(
event, event,
'presale:event.order.position', kwargs={ 'presale:event.order.position', kwargs={
'order': position.order.code, 'order': position.order.code,
@@ -500,7 +500,7 @@ def base_placeholders(sender, **kwargs):
} }
), ),
text_func=lambda event, position: _("View registration details"), text_func=lambda event, position: _("View registration details"),
sample_url_func=lambda event: eventreverse_absolute( sample_url_func=lambda event: build_absolute_uri(
event, event,
'presale:event.order.position', kwargs={ 'presale:event.order.position', kwargs={
'order': 'F8VVL', 'order': 'F8VVL',
@@ -511,14 +511,14 @@ def base_placeholders(sender, **kwargs):
sample_text_func=lambda event: _("View registration details"), sample_text_func=lambda event: _("View registration details"),
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url_info_change', ['position', 'event'], lambda position, event: eventreverse_absolute( 'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri(
event, event,
'presale:event.order.position.modify', kwargs={ 'presale:event.order.position.modify', kwargs={
'order': position.order.code, 'order': position.order.code,
'secret': position.web_secret, 'secret': position.web_secret,
'position': position.positionid 'position': position.positionid
} }
), lambda event: eventreverse_absolute( ), lambda event: build_absolute_uri(
event, event,
'presale:event.order.position.modify', kwargs={ 'presale:event.order.position.modify', kwargs={
'order': 'F8VVL', 'order': 'F8VVL',
@@ -528,14 +528,14 @@ def base_placeholders(sender, **kwargs):
), ),
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url_products_change', ['position', 'event'], lambda position, event: eventreverse_absolute( 'url_products_change', ['position', 'event'], lambda position, event: build_absolute_uri(
event, event,
'presale:event.order.position.change', kwargs={ 'presale:event.order.position.change', kwargs={
'order': position.order.code, 'order': position.order.code,
'secret': position.web_secret, 'secret': position.web_secret,
'position': position.positionid 'position': position.positionid
} }
), lambda event: eventreverse_absolute( ), lambda event: build_absolute_uri(
event, event,
'presale:event.order.position.change', kwargs={ 'presale:event.order.position.change', kwargs={
'order': 'F8VVL', 'order': 'F8VVL',
@@ -581,20 +581,20 @@ def base_placeholders(sender, **kwargs):
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url_remove', ['waiting_list_voucher', 'event'], 'url_remove', ['waiting_list_voucher', 'event'],
lambda waiting_list_voucher, event: eventreverse_absolute( lambda waiting_list_voucher, event: build_absolute_uri(
event, 'presale:event.waitinglist.remove' event, 'presale:event.waitinglist.remove'
) + '?voucher=' + waiting_list_voucher.code, ) + '?voucher=' + waiting_list_voucher.code,
lambda event: eventreverse_absolute( lambda event: build_absolute_uri(
event, event,
'presale:event.waitinglist.remove', 'presale:event.waitinglist.remove',
) + '?voucher=68CYU2H6ZTP3WLK5', ) + '?voucher=68CYU2H6ZTP3WLK5',
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url', ['waiting_list_voucher', 'event'], 'url', ['waiting_list_voucher', 'event'],
lambda waiting_list_voucher, event: eventreverse_absolute( lambda waiting_list_voucher, event: build_absolute_uri(
event, 'presale:event.redeem' event, 'presale:event.redeem'
) + '?voucher=' + waiting_list_voucher.code, ) + '?voucher=' + waiting_list_voucher.code,
lambda event: eventreverse_absolute( lambda event: build_absolute_uri(
event, event,
'presale:event.redeem', 'presale:event.redeem',
) + '?voucher=68CYU2H6ZTP3WLK5', ) + '?voucher=68CYU2H6ZTP3WLK5',
@@ -611,7 +611,7 @@ def base_placeholders(sender, **kwargs):
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join( 'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
'* {} - {}'.format( '* {} - {}'.format(
order.full_code, order.full_code,
eventreverse_absolute(event, 'presale:event.order.open', kwargs={ build_absolute_uri(event, 'presale:event.order.open', kwargs={
'event': event.slug, 'event': event.slug,
'organizer': event.organizer.slug, 'organizer': event.organizer.slug,
'order': order.code, 'order': order.code,
@@ -623,7 +623,7 @@ def base_placeholders(sender, **kwargs):
), lambda event: '\n' + '\n\n'.join( ), lambda event: '\n' + '\n\n'.join(
'* {} - {}'.format( '* {} - {}'.format(
'{}-{}'.format(event.slug.upper(), order['code']), '{}-{}'.format(event.slug.upper(), order['code']),
eventreverse_absolute(event, 'presale:event.order.open', kwargs={ build_absolute_uri(event, 'presale:event.order.open', kwargs={
'event': event.slug, 'event': event.slug,
'organizer': event.organizer.slug, 'organizer': event.organizer.slug,
'order': order['code'], 'order': order['code'],
@@ -662,13 +662,13 @@ def base_placeholders(sender, **kwargs):
# join vouchers with two spaces at end of line so markdown-parser inserts a <br> # join vouchers with two spaces at end of line so markdown-parser inserts a <br>
'voucher_url_list', ['event', 'voucher_list'], 'voucher_url_list', ['event', 'voucher_list'],
lambda event, voucher_list: ' \n'.join([ lambda event, voucher_list: ' \n'.join([
eventreverse_absolute( build_absolute_uri(
event, 'presale:event.redeem' event, 'presale:event.redeem'
) + '?voucher=' + c ) + '?voucher=' + c
for c in voucher_list for c in voucher_list
]), ]),
lambda event: ' \n'.join([ lambda event: ' \n'.join([
eventreverse_absolute( build_absolute_uri(
event, 'presale:event.redeem' event, 'presale:event.redeem'
) + '?voucher=' + c ) + '?voucher=' + c
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2'] for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
@@ -676,10 +676,10 @@ def base_placeholders(sender, **kwargs):
inline=False, inline=False,
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url', ['event', 'voucher_list'], lambda event, voucher_list: eventreverse_absolute(event, 'presale:event.index', kwargs={ 'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
'event': event.slug, 'event': event.slug,
'organizer': event.organizer.slug, 'organizer': event.organizer.slug,
}), lambda event: eventreverse_absolute(event, 'presale:event.index', kwargs={ }), lambda event: build_absolute_uri(event, 'presale:event.index', kwargs={
'event': event.slug, 'event': event.slug,
'organizer': event.organizer.slug, 'organizer': event.organizer.slug,
}) })
@@ -801,10 +801,11 @@ def get_available_placeholders(event, base_parameters, rich=False):
return params return params
def prepare_sample_context_for_preview(placeholder_to_sample): def get_sample_context(event, context_parameters, rich=True):
context_dict = {} context_dict = {}
lbl = _('This value will be replaced based on dynamic parameters.') lbl = _('This value will be replaced based on dynamic parameters.')
for k, sample in placeholder_to_sample.items(): for k, v in get_available_placeholders(event, context_parameters, rich=rich).items():
sample = v.render_sample(event)
if isinstance(sample, PlainHtmlAlternativeString): if isinstance(sample, PlainHtmlAlternativeString):
context_dict[k] = PlainHtmlAlternativeString( context_dict[k] = PlainHtmlAlternativeString(
'<{el} class="placeholder" title="{title}">{plain}</{el}>'.format( '<{el} class="placeholder" title="{title}">{plain}</{el}>'.format(
@@ -829,12 +830,3 @@ def prepare_sample_context_for_preview(placeholder_to_sample):
escape(sample) escape(sample)
)) ))
return context_dict return context_dict
def get_sample_context(event, context_parameters, rich=True):
return prepare_sample_context_for_preview(
{
k: v.render_sample(event)
for k, v in get_available_placeholders(event, context_parameters, rich=rich).items()
}
)
+3 -4
View File
@@ -44,7 +44,7 @@ from django.conf import settings
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import CachedFile, Event, User, cachedfile_name from pretix.base.models import CachedFile, Event, User, cachedfile_name
@@ -171,16 +171,15 @@ def shred(self, event: Event, fileid: str, confirm_code: str, user: int=None, lo
if user: if user:
with language(user.locale): with language(user.locale):
event_name = str(event.name)
mail( mail(
user.email, user.email,
gettext('Data shredding completed for %(event)s') % {'event': event_name}, _('Data shredding completed'),
'pretixbase/email/shred_completed.txt', 'pretixbase/email/shred_completed.txt',
{ {
'instance': settings.PRETIX_INSTANCE_NAME, 'instance': settings.PRETIX_INSTANCE_NAME,
'user': user, 'user': user,
'organizer': event.organizer.name, 'organizer': event.organizer.name,
'event': event_name, 'event': str(event.name),
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'), 'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
'shredders': ', '.join([str(s.verbose_name) for s in shredders]) 'shredders': ', '.join([str(s.verbose_name) for s in shredders])
}, },
+2 -2
View File
@@ -37,7 +37,7 @@ from pretix.base.services.mail import mail
from pretix.base.settings import GlobalSettingsObject from pretix.base.settings import GlobalSettingsObject
from pretix.base.signals import periodic_task from pretix.base.signals import periodic_task
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.urls import mainreverse_absolute from pretix.helpers.urls import build_absolute_uri
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@@ -121,7 +121,7 @@ def send_update_notification_email():
) )
), ),
{ {
'url': mainreverse_absolute('control:global.update') 'url': build_absolute_uri('control:global.update')
}, },
) )
+6 -28
View File
@@ -211,25 +211,12 @@ DEFAULTS = {
'form_class': forms.BooleanField, 'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField, 'serializer_class': serializers.BooleanField,
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Activate reusable media"), label=_("Activate re-usable media"),
help_text=_("The reusable media feature allows you to connect tickets and gift cards with physical media " help_text=_("The re-usable media feature allows you to connect tickets and gift cards with physical media "
"such as wristbands or chip cards that may be reused for different tickets or gift cards " "such as wristbands or chip cards that may be re-used for different tickets or gift cards "
"later.") "later.")
) )
}, },
'reusable_media_usage_enforced': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Enforce the usage of issued reusable media for check-in"),
help_text=_("If enabled, a ticket barcode will not be accepted anymore, if a reusable medium has been "
"created and linked to a ticket. Keeping this option turned off will treat the reusable "
"medium and ticket as equals."),
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-reusable_media_active'}),
)
},
'reusable_media_type_barcode': { 'reusable_media_type_barcode': {
'default': 'False', 'default': 'False',
'type': bool, 'type': bool,
@@ -1276,7 +1263,7 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField, 'serializer_class': serializers.BooleanField,
'write_permission': 'event.settings.invoicing:write', 'write_permission': 'event.settings.invoicing:write',
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Allow updating existing invoices"), label=_("Allow to update existing invoices"),
help_text=_("By default, invoices can never again be changed once they are issued. In most countries, we " help_text=_("By default, invoices can never again be changed once they are issued. In most countries, we "
"recommend to leave this option turned off and always issue a new invoice if a change needs " "recommend to leave this option turned off and always issue a new invoice if a change needs "
"to be made."), "to be made."),
@@ -1924,6 +1911,8 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField, 'serializer_class': serializers.BooleanField,
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Hide all past dates from calendar"), label=_("Hide all past dates from calendar"),
help_text=_("This option currently only affects the calendar of this event series, not the organizer-wide "
"calendar.")
) )
}, },
'allow_modifications': { 'allow_modifications': {
@@ -2284,17 +2273,6 @@ DEFAULTS = {
help_text=_("We'll show this publicly to allow attendees to contact you.") help_text=_("We'll show this publicly to allow attendees to contact you.")
) )
}, },
'contact_url': {
'default': None,
'type': str,
'serializer_class': serializers.URLField,
'form_class': forms.URLField,
'form_kwargs': dict(
label=_("Contact URL"),
help_text=_("If you set this, the footer contact link will point here instead of using the email address above. "
"Please note that you still need to add a contact email address that will be shared with all emails you send.")
)
},
'imprint_url': { 'imprint_url': {
'default': None, 'default': None,
'type': str, 'type': str,
+2 -3
View File
@@ -535,9 +535,8 @@ EventPluginRegistry = PluginAwareRegistry # for backwards compatibility
event_live_issues = EventPluginSignal() event_live_issues = EventPluginSignal()
""" """
This signal is sent out to determine whether an event can be taken live. If you want to This signal is sent out to determine whether an event can be taken live. If you want to
prevent the event from going live, return an error message to display to the user (either prevent the event from going live, return a string that will be displayed to the user
as a SafeString containing HTML, or a string that will be HTML-escaped). If you don't, as the error message. If you don't, your receiver should return ``None``.
your receiver should return ``None``.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event. As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
""" """
+1 -1
View File
@@ -11,7 +11,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}"> <link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
<script type="text/javascript" src="{% static "pretixbase/js/errors.js" %}"></script>
{% block custom_header %}{% endblock %} {% block custom_header %}{% endblock %}
{% if css_theme %} {% if css_theme %}
<link rel="stylesheet" type="text/css" href="{{ css_theme }}" /> <link rel="stylesheet" type="text/css" href="{{ css_theme }}" />
@@ -22,4 +21,5 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</body> </body>
<script src="{% static "pretixbase/js/errors.js" %}"></script>
</html> </html>
@@ -1,14 +1,10 @@
{% load i18n %} {% load i18n %}
{% trans "You requested to cancel an event that involves a large bulk refund:" %} {% trans "You have requested us to cancel an event which includes a larger bulk-refund:" %}
- {% trans "Event" %}: {{ event }} {% trans "Event" %}: {{ event }}
- {% trans "Estimated refund" %}: **{{ amount }}**
{% trans "To confirm, paste the following code into the cancellation form:" %} {% trans "Estimated refund amount" %}: **{{ amount }}**
{{ confirmation_code }} {% trans "Please confirm that you want to proceed by coping the following confirmation code into the cancellation form:" %}
{% blocktrans with instance=instance %}Don't share this code with anyone. The {{ instance }} team will never ask you for it.{% endblocktrans %} **{{ confirmation_code }}**
{% blocktrans with instance=instance %}Thanks,
The {{ instance }} Team{% endblocktrans %}
@@ -1,13 +1,12 @@
{% load i18n %} {% load i18n %}
{% trans "Your scheduled export failed." %} {% trans "Your export failed." %}
- {% trans "Reason" %}: {{ reason }} {% trans "Reason:" %} {{ reason }}
{% if not soft %}{% trans "If an export fails five times in a row, we'll stop sending it." %}{% endif %} {% if not soft %}
{% trans "If your export fails five times in a row, it will no longer be sent." %}
{% endif %}
{% trans "You can adjust or remove this export here:" %} {% trans "Configuration link:" %}
{{ configuration_url }} {{ configuration_url }}
{% blocktrans with instance=instance %}Thanks,
The {{ instance }} Team{% endblocktrans %}
@@ -52,13 +52,13 @@
<table cellpadding="20"><tr><td> <table cellpadding="20"><tr><td>
<![endif]--> <![endif]-->
<div class="content"> <div class="content">
{% trans "You're receiving this email based on your notification settings." %}<br> {% trans "You receive these emails based on your notification settings." %}<br>
<a href="{{ settings_url }}"> <a href="{{ settings_url }}">
{% trans "Manage settings" %} {% trans "Click here to view and change your notification settings" %}
</a> </a>
{% if disable_url %}<br> {% if disable_url %}<br>
<a href="{{ disable_url }}"> <a href="{{ disable_url }}">
{% trans "Disable all notifications" %} {% trans "Click here disable all notifications immediately." %}
</a> </a>
{% endif %} {% endif %}
</div> </div>
@@ -1,21 +1,19 @@
{% load i18n %} {% load i18n %}
{{ notification.title }}{% if notification.detail %} {{ notification.title }}{% if notification.detail %}
{{ notification.detail }}{% endif %}{% if notification.url %} {{ notification.detail }}
{% endif %}{% if notification.url %}
{{ notification.url }}{% endif %}{% if notification.attributes %} {{ notification.url }}{% endif %}{% for attr in notification.attributes %}
{% for attr in notification.attributes %}- {{ attr.title }}: {{ attr.value }} {{ attr.title }}: {{ attr.value }}{% endfor %}{% for action in notification.actions %}
{% endfor %}{% endif %}{% for action in notification.actions %}
{{ action.label }}: {{ action.label }}
{{ action.url }}{% endfor %}
{{ action.url }}{% endfor %} {% trans "You receive these emails based on your notification settings." %}
{% trans "Click here to view and change your notification settings:" %}
--- {{ settings_url }}
{% if disable_url %}{% trans "Click here disable all notifications immediately:" %}
{% trans "You're receiving this email based on your notification settings." %} {{ disable_url }}
- {% trans "Manage settings" %}: {{ settings_url }}
{% if disable_url %}- {% trans "Disable all notifications" %}: {{ disable_url }}
{% endif %} {% endif %}
@@ -1,14 +1,17 @@
{% load i18n %}{% blocktrans %}Hello, {% load i18n %}
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
The following data shredding job has been completed: we hereby confirm that the following data shredding job has been completed:
- Organizer: {{ organizer }} Organizer: {{ organizer }}
- Event: {{ event }}
- Data selection: {{ shredders }}
- Start time: {{ start_time }}
Any data added to the event after the start time may not have been deleted. Event: {{ event }}
Thanks, Data selection: {{ shredders }}
The {{ instance }} Team
Start time: {{ start_time }} (new data added after this time might not have been deleted)
Best regards,
Your {{ instance }} team
{% endblocktrans %} {% endblocktrans %}

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