Compare commits

..
686 changed files with 612510 additions and 645793 deletions
-1
View File
@@ -1,6 +1,5 @@
doc/ doc/
env/ env/
node_modules/
res/ res/
local/ local/
.git/ .git/
-5
View File
@@ -1,5 +0,0 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = tab
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
-2
View File
@@ -1,2 +0,0 @@
# Format pre-vue code with eslint where possible (2026-09-10)
d85e52c83ed3639e040372fd0052e842e4899d90
+1 -4
View File
@@ -46,7 +46,4 @@ jobs:
- name: Run build - name: Run build
run: python -m build run: python -m build
- name: Check files - name: Check files
run: | run: unzip -l dist/pretix*whl | grep node_modules || exit 1
for pat in 'static.dist/vite/widget/widget.js' 'static.dist/vite/control/assets/checkinrules/main-' 'static.dist/vite/control/assets/webcheckin/main-'; do
unzip -l dist/pretix*whl | grep -q "$pat" || { echo "Missing: $pat"; exit 1; }
done
-43
View File
@@ -1,43 +0,0 @@
name: SBOM
on:
push:
branches: [ master, sbom ]
tags: [ 'v.*' ]
permissions:
contents: read # to fetch code (actions/checkout)
env:
FORCE_COLOR: 1
jobs:
test:
runs-on: ubuntu-22.04
name: Submission
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Use Node.js
uses: actions/setup-node@v7
with:
node-version: '24.x'
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install system dependencies
run: sudo apt update && sudo apt install -y gettext unzip
- name: Install Python dependencies
run: pip3 install -U "prisma-sbom-submit[python]"
- name: Create SBOM
run: NPM=$(which npm) prisma-sbom-submit collect . sbom.json
- name: Submit SBOM
run: prisma-sbom-submit upload --server https://prisma.pretix.com sbom.json
env:
PRISMA_UPLOAD_TOKEN: ${{ secrets.PRISMA_UPLOAD_TOKEN }}
-43
View File
@@ -1,43 +0,0 @@
name: JS Code Style
on:
push:
branches: [ master ]
paths:
- 'src/pretix/static/pretixpresale/widget/**'
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
- 'src/pretix/plugins/webcheckin/**'
- 'eslint.config.mjs'
- 'package.json'
- 'package-lock.json'
pull_request:
branches: [ master ]
paths:
- 'src/pretix/static/pretixpresale/widget/**'
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
- 'src/pretix/plugins/webcheckin/**'
- 'eslint.config.mjs'
- 'package.json'
- 'package-lock.json'
permissions:
contents: read
env:
FORCE_COLOR: 1
jobs:
eslint:
name: eslint
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install Dependencies
run: npm ci
- name: Run ESLint
run: npm run lint:eslint
+1 -61
View File
@@ -72,7 +72,7 @@ jobs:
run: make all compress run: make all compress
- name: Run tests - name: Run tests
working-directory: ./src working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --ignore=tests/e2e --maxfail=100 run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --maxfail=100
- name: Run concurrency tests - name: Run concurrency tests
working-directory: ./src working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reuse-db run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reuse-db
@@ -84,63 +84,3 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false fail_ci_if_error: false
if: matrix.database == 'postgres' && matrix.python-version == '3.13' if: matrix.database == 'postgres' && matrix.python-version == '3.13'
e2e:
runs-on: ubuntu-22.04
name: E2E Tests
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: pretix
options: >-
--health-cmd "pg_isready -U postgres -d pretix"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install system dependencies
run: sudo apt update && sudo apt install -y gettext
- name: Install Python dependencies
run: pip3 install uv && uv pip install --system -e ".[dev]" psycopg2-binary
- name: Install JS dependencies
working-directory: ./src
run: make npminstall
- name: Compile
working-directory: ./src
run: make all compress
- name: Install Playwright browsers
run: playwright install --with-deps
- name: Run E2E tests
working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10 --tracing=retain-on-failure
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-traces
path: test-results/
- name: Log trace instructions
if: steps.check-traces.outputs.found == 'true'
run: |
{
echo "## 🎭 Playwright traces available"
echo ""
echo "Some tests failed or retried and produced traces."
echo ""
echo "1. Download the **playwright-traces-${{ github.run_id }}** artifact from this run (link in the **Summary** tab, under Artifacts)."
echo "2. Unzip it."
echo "3. Go to https://trace.playwright.dev and drag \`trace.zip\` into the page — or run \`npx playwright show-trace trace.zip\` locally."
} >> "$GITHUB_STEP_SUMMARY"
-2
View File
@@ -24,7 +24,5 @@ local/
.project .project
.pydevproject .pydevproject
.DS_Store .DS_Store
node_modules/
.vite/
+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/
+1 -1
View File
@@ -1 +1 @@
24 17
-1
View File
@@ -1 +0,0 @@
/*
+5 -8
View File
@@ -1,7 +1,6 @@
FROM python:3.13-trixie FROM python:3.13-trixie
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ RUN apt-get update && \
apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
build-essential \ build-essential \
gettext \ gettext \
@@ -22,7 +21,8 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
libmaxminddb0 \ libmaxminddb0 \
libmaxminddb-dev \ libmaxminddb-dev \
zlib1g-dev \ zlib1g-dev \
nodejs && \ nodejs \
npm && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
dpkg-reconfigure locales && \ dpkg-reconfigure locales && \
@@ -50,14 +50,11 @@ COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
COPY pyproject.toml /pretix/pyproject.toml COPY pyproject.toml /pretix/pyproject.toml
COPY _build /pretix/_build COPY _build /pretix/_build
COPY src /pretix/src COPY src /pretix/src
COPY package.json /pretix/package.json
COPY package-lock.json /pretix/package-lock.json
COPY tsconfig.json /pretix/tsconfig.json
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]" \
+2 -5
View File
@@ -16,6 +16,8 @@ recursive-include src/pretix/plugins/banktransfer/templates *
recursive-include src/pretix/plugins/banktransfer/static * recursive-include src/pretix/plugins/banktransfer/static *
recursive-include src/pretix/plugins/manualpayment/templates * recursive-include src/pretix/plugins/manualpayment/templates *
recursive-include src/pretix/plugins/manualpayment/static * recursive-include src/pretix/plugins/manualpayment/static *
recursive-include src/pretix/plugins/paypal/templates *
recursive-include src/pretix/plugins/paypal/static *
recursive-include src/pretix/plugins/paypal2/templates * recursive-include src/pretix/plugins/paypal2/templates *
recursive-include src/pretix/plugins/paypal2/static * recursive-include src/pretix/plugins/paypal2/static *
recursive-include src/pretix/plugins/src/pretixdroid/templates * recursive-include src/pretix/plugins/src/pretixdroid/templates *
@@ -46,8 +48,3 @@ recursive-include src Makefile
recursive-exclude doc * recursive-exclude doc *
recursive-exclude deployment * recursive-exclude deployment *
recursive-exclude res * recursive-exclude res *
include package.json
include package-lock.json
include tsconfig.json
include vite.config.ts
+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``.
-241
View File
@@ -1,241 +0,0 @@
Event Meta Properties
=====================
Resource description
--------------------
An event meta property is used to to define meta information fields for its events.
This information can be re-used, for example, in ticket layouts.
The event meta property resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Unique ID for this property
name string Name of the property
default string Value of the default option
required boolean If ``true``, an event can only be taken live if the
property is set. In event series, it's always optional
to set a value for individual dates
protected boolean If ``true``, the value for an event can only be changed
by organizer-level administrators
filter_public boolean If ``true``, this property will be shown to filter
events in the public event list and calendar
public_label string Public name of the property
filter_allowed boolean If ``true``, this property will be shown to filter
events or reports in the backend, and it can also be
used for hidden filter parameters in the frontend
choices list of objects List of JSON objects representing all permitted values
for this property, or ``null`` for no limitation.
Each choice object has a required internal name named
``key`` and optional public name named ``label``
consisting of a dictionary of i18n string translations
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/event_meta_properties/
Returns a list of all meta properties for the organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/meta_properties/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"name": "Color",
"default": "blue",
"required": false,
"protected": false,
"filter_public": false,
"public_label": {},
"filter_allowed": true,
"choices": [
{
"key": "blue",
"label": {
"en": "Blue"
},
}
]
}
]
}
:param organizer: The ``slug`` field of the organizer
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
Returns information on one property, identified by its id.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/event_meta_properties/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
{
"id": 1,
"name": "Color",
"default": "blue",
"required": false,
"protected": false,
"filter_public": false,
"public_label": {},
"filter_allowed": true,
"choices": null
}
:param organizer: The ``slug`` field of the organizer
:param id: The ``id`` field of the meta property to retrieve
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/event_meta_properties/
Creates a new meta property
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/event_meta_properties/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "ref-code",
"default": "abcde",
"required": true,
"choices": null
}
**Example response**:
.. sourcecode:: http
{
"id": 2,
"name": "reference",
"default": "abcde",
"required": true,
"protected": false,
"filter_public": false,
"public_label": null,
"filter_allowed": true,
"choices": null
}
:param organizer: The ``slug`` field of the organizer
:statuscode 201: no error
:statuscode 400: The meta property could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
Update a meta property. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide
all fields of the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the
fields that you want to change.
You can change all fields of the resource except the ``id`` field.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/event_meta_properties/2/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"required": false
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 3,
"name": "reference",
"default": "abcde",
"required": false,
"protected": false,
"filter_public": false,
"public_label": null,
"filter_allowed": true,
"choices": null
}
:param organizer: The ``slug`` field of the organizer
:param id: The ``id`` field of the meta property to modify
:statuscode 200: no error
:statuscode 400: The property could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
Delete a meta property.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/event_meta_properties/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer
:param id: The ``id`` field of the meta property to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource.
+15 -17
View File
@@ -110,7 +110,7 @@ Endpoints
"plugins": [ "plugins": [
"pretix.plugins.banktransfer", "pretix.plugins.banktransfer",
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal2", "pretix.plugins.paypal",
"pretix.plugins.ticketoutputpdf" "pretix.plugins.ticketoutputpdf"
], ],
"all_sales_channels": false, "all_sales_channels": false,
@@ -199,7 +199,7 @@ Endpoints
"plugins": [ "plugins": [
"pretix.plugins.banktransfer", "pretix.plugins.banktransfer",
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal2", "pretix.plugins.paypal",
"pretix.plugins.ticketoutputpdf" "pretix.plugins.ticketoutputpdf"
], ],
"valid_keys": { "valid_keys": {
@@ -262,7 +262,7 @@ Endpoints
"item_meta_properties": {}, "item_meta_properties": {},
"plugins": [ "plugins": [
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal2" "pretix.plugins.paypal"
], ],
"all_sales_channels": true, "all_sales_channels": true,
"limit_sales_channels": [] "limit_sales_channels": []
@@ -299,7 +299,7 @@ Endpoints
"item_meta_properties": {}, "item_meta_properties": {},
"plugins": [ "plugins": [
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal2" "pretix.plugins.paypal"
], ],
"all_sales_channels": true, "all_sales_channels": true,
"limit_sales_channels": [], "limit_sales_channels": [],
@@ -364,7 +364,7 @@ Endpoints
"item_meta_properties": {}, "item_meta_properties": {},
"plugins": [ "plugins": [
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal2" "pretix.plugins.paypal"
], ],
"all_sales_channels": true, "all_sales_channels": true,
"limit_sales_channels": [] "limit_sales_channels": []
@@ -401,7 +401,7 @@ Endpoints
"item_meta_properties": {}, "item_meta_properties": {},
"plugins": [ "plugins": [
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal2" "pretix.plugins.paypal"
], ],
"all_sales_channels": true, "all_sales_channels": true,
"limit_sales_channels": [], "limit_sales_channels": [],
@@ -438,7 +438,7 @@ Endpoints
"plugins": [ "plugins": [
"pretix.plugins.banktransfer", "pretix.plugins.banktransfer",
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal2", "pretix.plugins.paypal",
"pretix.plugins.pretixdroid" "pretix.plugins.pretixdroid"
] ]
} }
@@ -475,7 +475,7 @@ Endpoints
"plugins": [ "plugins": [
"pretix.plugins.banktransfer", "pretix.plugins.banktransfer",
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal2", "pretix.plugins.paypal",
"pretix.plugins.pretixdroid" "pretix.plugins.pretixdroid"
], ],
"all_sales_channels": true, "all_sales_channels": true,
@@ -566,7 +566,7 @@ organizer level.
Content-Type: application/json Content-Type: application/json
{ {
"region": "DE", "imprint_url": "https://pretix.eu",
… …
} }
@@ -579,14 +579,12 @@ organizer level.
Content-Type: application/json Content-Type: application/json
{ {
"region": "imprint_url":
{ {
"value": "DE", "value": "https://pretix.eu",
"label": "Region", "label": "Imprint URL",
"readonly": false, "readonly": false,
"help_text": "Will be used to determine date and time formatting as well as default country for customer "help_text": "This should point e.g. to a part of your website that has your contact details and legal information."
addresses and phone numbers. For formatting, this takes less priority than the language and
is therefore mostly relevant for languages used in different regions globally (like English)."
} }
}, },
… …
@@ -622,7 +620,7 @@ organizer level.
Content-Type: application/json Content-Type: application/json
{ {
"region": "DE" "imprint_url": "https://example.org/imprint/"
} }
**Example response**: **Example response**:
@@ -634,7 +632,7 @@ organizer level.
Content-Type: application/json Content-Type: application/json
{ {
"region": "DE", "imprint_url": "https://example.org/imprint/",
… …
} }
-184
View File
@@ -844,187 +844,3 @@ You can also fetch existing leads (if you are authorized to do so):
:statuscode 200: No error :statuscode 200: No error
:statuscode 401: Invalid authentication code :statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data :statuscode 403: Not permitted to access bulk data
Retrieving Vouchers
"""""""""""""""""""
Vouchers returned by the App API use a different format than described in :ref:`rest-vouchers`.
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the voucher
code string The voucher code that is required to redeem the voucher
max_usages integer The maximum number of times this voucher can be
redeemed (default: 1).
redeemed integer The number of times this voucher already has been
redeemed.
valid_until datetime The voucher expiration date (or ``null``).
subevent string Name of the date inside an event series this voucher belongs to (or ``null``).
tag string A string that is used for grouping vouchers
comment string An internal exhibitor comment on the voucher.
items list of strings A list of items this voucher is restricted to (or ``null``).
price_mode string Determines how this voucher affects product prices.
Possible values:
* ``none`` – No effect on price
* ``set`` – The product price is set to the given ``value``
* ``subtract`` – The product price is determined by the original price *minus* the given ``value``
* ``percent`` – The product price is determined by the original price reduced by the percentage given in ``value``
value decimal (string) The value (see ``price_mode``)
redemptions list of objects A list of objects, where each object represents an order position that has been purchased using the voucher.
Each entry will contains the fields ``attendee_fields``, ``redemption_date`` and ``subevent``.
The attendee data in the ``attendee_fields`` that is shown is based on the event's configuration, and each entry
contains the fields ``id``, ``label``, ``value``, and ``details``. ``details`` is usually empty
except in a few cases where it contains an additional list of objects
with ``value`` and ``label`` keys (e.g. splitting of names).
===================================== ========================== =======================================================
.. http:get:: /exhibitors/api/v1/vouchers/
Returns a list of all vouchers connected to the exhibitor.
Note that the ``attendee_fields`` array can contain any number of dynamic keys!
Depending on the exhibitors permission and event configuration this might be empty, or contain lots of details.
The app should dynamically show these values (read-only) with the labels sent by the server.
**Example request**:
.. sourcecode:: http
GET /exhibitors/api/v1/vouchers/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
"valid_until": null,
"subevent": null,
"tag": "testvoucher",
"comment": "",
"items": [
"All"
],
"price_mode": "set",
"value": "12.00",
"redemptions": [
{
"attendee_fields": [
{
"id": "attendee_name",
"label": "Name",
"value": "Jon Doe",
"details": [
{"label": "Given name", "value": "John"},
{"label": "Family name", "value": "Doe"},
]
},
{
"id": "attendee_email",
"label": "Email",
"value": "test@example.com",
"details": []
}
],
"redemption_date": "2026-05-06",
"subevent": null
},
]
}
]
}
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data
.. http:get:: /exhibitors/api/v1/vouchers/(id)/
Returns the details of a single, specific voucher connected to the exhibitor.
Note that the ``attendee_fields`` array can contain any number of dynamic keys!
Depending on the exhibitors permission and event configuration this might be empty, or contain lots of details.
The app should dynamically show these values (read-only) with the labels sent by the server.
**Example request**:
.. sourcecode:: http
GET /exhibitors/api/v1/vouchers/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
"valid_until": null,
"subevent": null,
"tag": "testvoucher",
"comment": "",
"items": [
"All"
],
"price_mode": "set",
"value": "12.00",
"redemptions": [
{
"attendee_fields": [
{
"id": "attendee_name",
"label": "Name",
"value": "Jon Doe",
"details": [
{"label": "Given name", "value": "John"},
{"label": "Family name", "value": "Doe"},
]
},
{
"id": "attendee_email",
"label": "Email",
"value": "test@example.com",
"details": []
}
],
"redemption_date": "2026-05-06",
"subevent": null
},
]
}
:param id: The ``id`` field of the voucher to fetch
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data
:statuscode 404: Voucher not found in system
-1
View File
@@ -12,7 +12,6 @@ at :ref:`plugin-docs`.
organizers organizers
events events
subevents subevents
event_meta_properties
taxrules taxrules
categories categories
items items
+6 -13
View File
@@ -16,7 +16,6 @@ Field Type Description
id integer Internal ID of the program time id integer Internal ID of the program time
start datetime The start date time for this program time slot. start datetime The start date time for this program time slot.
end datetime The end date time for this program time slot. end datetime The end date time for this program time slot.
location multi-lingual string The program time slot's location (or ``null``)
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: TODO .. versionchanged:: TODO
@@ -55,20 +54,17 @@ Endpoints
{ {
"id": 2, "id": 2,
"start": "2025-08-14T22:00:00Z", "start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z", "end": "2025-08-15T00:00:00Z"
"location": null
}, },
{ {
"id": 3, "id": 3,
"start": "2025-08-12T22:00:00Z", "start": "2025-08-12T22:00:00Z",
"end": "2025-08-13T22:00:00Z", "end": "2025-08-13T22:00:00Z"
"location": null
}, },
{ {
"id": 14, "id": 14,
"start": "2025-08-15T22:00:00Z", "start": "2025-08-15T22:00:00Z",
"end": "2025-08-17T22:00:00Z", "end": "2025-08-17T22:00:00Z"
"location": null
} }
] ]
} }
@@ -103,8 +99,7 @@ Endpoints
{ {
"id": 1, "id": 1,
"start": "2025-08-15T22:00:00Z", "start": "2025-08-15T22:00:00Z",
"end": "2025-10-27T23:00:00Z", "end": "2025-10-27T23:00:00Z"
"location": null
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -130,8 +125,7 @@ Endpoints
{ {
"start": "2025-08-15T10:00:00Z", "start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z", "end": "2025-08-15T22:00:00Z"
"location": null
} }
**Example response**: **Example response**:
@@ -145,8 +139,7 @@ Endpoints
{ {
"id": 17, "id": 17,
"start": "2025-08-15T10:00:00Z", "start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z", "end": "2025-08-15T22:00:00Z"
"location": null
} }
:param organizer: The ``slug`` field of the organizer of the event/item to create a program time for :param organizer: The ``slug`` field of the organizer of the event/item to create a program time for
+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",
} }
], ],
-5
View File
@@ -65,7 +65,6 @@ valid_date_max date Maximum value f
valid_datetime_min datetime Minimum value for date and time questions (optional) valid_datetime_min datetime Minimum value for date and time questions (optional)
valid_datetime_max datetime Maximum value for date and time questions (optional) valid_datetime_max datetime Maximum value for date and time questions (optional)
valid_file_portrait boolean Turn on file validation for portrait photos valid_file_portrait boolean Turn on file validation for portrait photos
valid_string_length_min integer Minimum length for string questions (optional)
valid_string_length_max integer Maximum length for string questions (optional) valid_string_length_max integer Maximum length for string questions (optional)
dependency_question integer Internal ID of a different question. The current dependency_question integer Internal ID of a different question. The current
question will only be shown if the question given in question will only be shown if the question given in
@@ -131,7 +130,6 @@ Endpoints
"valid_date_max": null, "valid_date_max": null,
"valid_datetime_min": null, "valid_datetime_min": null,
"valid_datetime_max": null, "valid_datetime_max": null,
"valid_string_length_min": null,
"valid_string_length_max": null, "valid_string_length_max": null,
"valid_file_portrait": false, "valid_file_portrait": false,
"dependency_question": null, "dependency_question": null,
@@ -213,7 +211,6 @@ Endpoints
"valid_datetime_min": null, "valid_datetime_min": null,
"valid_datetime_max": null, "valid_datetime_max": null,
"valid_file_portrait": false, "valid_file_portrait": false,
"valid_string_length_min": null,
"valid_string_length_max": null, "valid_string_length_max": null,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
@@ -318,7 +315,6 @@ Endpoints
"valid_datetime_min": null, "valid_datetime_min": null,
"valid_datetime_max": null, "valid_datetime_max": null,
"valid_file_portrait": false, "valid_file_portrait": false,
"valid_string_length_min": null,
"valid_string_length_max": null, "valid_string_length_max": null,
"options": [ "options": [
{ {
@@ -403,7 +399,6 @@ Endpoints
"valid_datetime_min": null, "valid_datetime_min": null,
"valid_datetime_max": null, "valid_datetime_max": null,
"valid_file_portrait": false, "valid_file_portrait": false,
"valid_string_length_min": null,
"valid_string_length_max": null, "valid_string_length_max": null,
"options": [ "options": [
{ {
+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
-1
View File
@@ -116,7 +116,6 @@ Endpoints
:query integer page: The page number in case of a multi-page result set, default is 1 :query integer page: The page number in case of a multi-page result set, default is 1
:query string code: Only show the voucher with the given voucher code. :query string code: Only show the voucher with the given voucher code.
:query string search: Only show the voucher with the given query found in the code, tag, or comment.
:query integer max_usages: Only show vouchers with the given maximal number of usages. :query integer max_usages: Only show vouchers with the given maximal number of usages.
:query integer redeemed: Only show vouchers with the given number of redemptions. Note that this doesn't tell you if :query integer redeemed: Only show vouchers with the given number of redemptions. Note that this doesn't tell you if
the voucher can still be redeemed, as this also depends on ``max_usages``. See the the voucher can still be redeemed, as this also depends on ``max_usages``. See the
-1
View File
@@ -70,7 +70,6 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.subevent.changed`` * ``pretix.subevent.changed``
* ``pretix.subevent.deleted`` * ``pretix.subevent.deleted``
* ``pretix.event.item.*`` * ``pretix.event.item.*``
* ``pretix.event.quota.*``
* ``pretix.event.live.activated`` * ``pretix.event.live.activated``
* ``pretix.event.live.deactivated`` * ``pretix.event.live.deactivated``
* ``pretix.event.testmode.activated`` * ``pretix.event.testmode.activated``
+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:
+2 -2
View File
@@ -86,7 +86,7 @@ individual commits, we use "Rebase and merge" instead. Merge commits should be a
.. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/ .. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/
.. _flake8: https://pypi.python.org/pypi/flake8 .. _flake8: https://pypi.python.org/pypi/flake8
.. _Django Coding Style: https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/ .. _Django Coding Style: https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/
.. _translation: https://docs.djangoproject.com/en/6.0/topics/i18n/translation/ .. _translation: https://docs.djangoproject.com/en/1.11/topics/i18n/translation/
.. _class-based views: https://docs.djangoproject.com/en/6.0/topics/class-based-views/ .. _class-based views: https://docs.djangoproject.com/en/1.11/topics/class-based-views/
.. _pytest-style: https://docs.pytest.org/en/latest/assert.html .. _pytest-style: https://docs.pytest.org/en/latest/assert.html
.. _fixtures: https://docs.pytest.org/en/latest/fixture.html .. _fixtures: https://docs.pytest.org/en/latest/fixture.html
+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 -51
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::
@@ -110,56 +110,6 @@ process::
However, beware that code changes will not auto-reload within Celery. However, beware that code changes will not auto-reload within Celery.
Running the local development server will also automatically start a vite dev server for all control vue components.
Run the widget development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To locally develop the presale widget you need to start a separate vite dev server using::
npm run dev:widget
You can control the org, event and much more via query parameters like this::
http://localhost:5180/?org=testorg&event=testevent
The following query parameters are supported:
.. list-table::
:header-rows: 1
:widths: 20 20 60
* - Parameter
- Default
- Description
* - ``org``
- ``testorg``
- Organization slug
* - ``event``
- ``testevent``
- Event slug
* - ``host``
- ``http://localhost:8000``
- Backend host URL
* - ``type``
- ``widget``
- Element type: ``widget`` or ``button``
* - ``mode``
- ``dev``
- ``dev`` loads the Vite dev source, ``prod`` loads the built ``v2.{lang}.js``
* - ``lang``
- ``de``
- Language code for the prod script
* - ``button-text``
- ``Buy tickets!``
- Text content for the button (only used when ``type=button``)
Any other query parameter is passed through as an attribute on the widget/button element.
For example, ``?skip-ssl-check&list-type=calendar&items=123`` adds those attributes directly.
.. _`checksandtests`: .. _`checksandtests`:
Code checks and unit tests Code checks and unit tests
-165
View File
@@ -1,165 +0,0 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import ts from 'typescript-eslint'
import stylistic from '@stylistic/eslint-plugin'
import vue from 'eslint-plugin-vue'
import vuePug from 'eslint-plugin-vue-pug'
const ignores = globalIgnores([
'**/node_modules',
'**/dist',
// Vendored code
'src/pretix/static/leaflet',
'src/pretix/static/clipboard',
'src/pretix/static/cropper',
'src/pretix/static/lightbox',
'src/pretix/static/are-you-sure',
'src/pretix/static/vuejs',
'src/pretix/static/fontawesome',
'src/pretix/static/typeahead',
'src/pretix/static/moment',
'src/pretix/static/pdfjs',
'src/pretix/static/sortable',
'src/pretix/static/iframeresizer',
'src/pretix/static/bootstrap',
'src/pretix/static/d3',
'src/pretix/static/jsi18n',
'src/pretix/static/fabric',
'src/pretix/static/datetimepicker',
'src/pretix/static/charts',
'src/pretix/static/fileupload',
'src/pretix/static/seating',
'src/pretix/static/rest_framework',
'src/pretix/static/select2',
'src/pretix/static/schema',
'src/pretix/static/slider',
'src/pretix/static/jquery',
'src/pretix/static/colorpicker',
'src/pretix/static/rrule',
'src/pretix/static/pretixcontrol/js/jquery.qrcode.min.js',
'src/pretix/static/pretixpresale/js/widget/docready.js',
// Pre-vue JS code
'src/pretix/static/pretixbase/js/addressform.js',
'src/pretix/static/pretixbase/js/asynctask.js',
'src/pretix/static/pretixbase/js/details.js',
'src/pretix/static/pretixbase/js/gettextstub.js',
'src/pretix/static/pretixbase/js/i18nstring.js',
'src/pretix/static/pretixcontrol/js/menu.js',
'src/pretix/static/pretixcontrol/js/ui/editor.js',
'src/pretix/static/pretixcontrol/js/ui/geo.js',
'src/pretix/static/pretixcontrol/js/ui/main.js',
'src/pretix/static/pretixcontrol/js/ui/plugins.js',
'src/pretix/static/pretixcontrol/js/ui/subevent.js',
'src/pretix/static/pretixcontrol/js/ui/variations.js',
'src/pretix/static/pretixcontrol/js/ui/webauthn.js',
'src/pretix/static/pretixpresale/js/ui/cart.js',
'src/pretix/static/pretixpresale/js/ui/main.js',
'src/pretix/static/pretixpresale/js/ui/questions.js',
'src/pretix/static/pretixpresale/js/widget/floatformat.js',
'src/pretix/static/pretixpresale/js/widget/widget.js',
'src/pretix/plugins/banktransfer/static',
'src/pretix/plugins/paypal2/static',
'src/pretix/plugins/statistics/static',
'src/pretix/plugins/stripe/static',
// Plugin checkouts
'local',
// docs
'doc',
])
export default defineConfig([
ignores,
...ts.config(
js.configs.recommended,
ts.configs.recommended
),
stylistic.configs.customize({
indent: 'tab',
braceStyle: '1tbs',
quoteProps: 'as-needed'
}),
...vue.configs['flat/recommended'],
...vuePug.configs['flat/recommended'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
localStorage: false,
$: 'readonly',
$$: 'readonly',
$ref: 'readonly',
$computed: 'readonly',
},
parserOptions: {
parser: '@typescript-eslint/parser'
}
},
rules: {
'no-debugger': 'off',
curly: 0,
'no-return-assign': 0,
'no-console': 'off',
'vue/require-default-prop': 0,
'vue/require-v-for-key': 0,
'vue/valid-v-for': 'warn',
'vue/no-reserved-keys': 0,
'vue/no-setup-props-destructure': 0,
'vue/multi-word-component-names': 0,
'vue/max-attributes-per-line': 0,
'vue/attribute-hyphenation': ['warn', 'never'],
'vue/v-on-event-hyphenation': ['warn', 'never'],
'import/first': 0,
'@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/no-explicit-any': 0,
'no-use-before-define': 'off',
'no-var': 'error',
'@typescript-eslint/no-use-before-define': ['error', {
typedefs: false,
functions: false,
}],
'@typescript-eslint/no-unused-vars': ['error', {
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true
}],
'@stylistic/comma-dangle': 0,
'@stylistic/space-before-function-paren': ['error', 'always'],
'@stylistic/max-statements-per-line': ['error', { max: 1, ignoredNodes: ['BreakStatement'] }],
'@stylistic/member-delimiter-style': 0,
'@stylistic/arrow-parens': 0,
'@stylistic/generator-star-spacing': 0,
'@stylistic/yield-star-spacing': ['error', 'after'],
},
},
{
files: [
'src/pretix/static/pretixcontrol/js/ui/checkinrules/**/*.vue',
'src/pretix/plugins/webcheckin/**/*.vue',
],
languageOptions: {
globals: {
moment: 'readonly',
},
},
},
{
files: [
'src/pretix/static/pretixpresale/widget/**/*.{ts,vue}',
],
languageOptions: {
globals: {
LANG: 'readonly',
},
},
},
])
-4813
View File
File diff suppressed because it is too large Load Diff
-52
View File
@@ -1,52 +0,0 @@
{
"name": "pretix",
"version": "1.0.0",
"description": "",
"homepage": "https://github.com/pretix/pretix#readme",
"bugs": {
"url": "https://github.com/pretix/pretix/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/pretix/pretix.git"
},
"license": "SEE LICENSE IN LICENSE",
"author": "",
"type": "module",
"main": "index.js",
"directories": {
"doc": "doc"
},
"scripts": {
"dev:control": "vite",
"dev:widget": "vite src/pretix/static/pretixpresale/widget",
"build": "npm run build:control -s && npm run build:widget -s",
"build:control": "vite build",
"build:widget": "vite build src/pretix/static/pretixpresale/widget",
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"vue": "^3.5.30"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/jquery": "^3.5.33",
"@types/moment": "^2.11.29",
"@types/node": "^25.5.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/language-plugin-pug": "^3.2.5",
"eslint": "^10.0.3",
"eslint-plugin-vue": "^10.8.0",
"eslint-plugin-vue-pug": "^1.0.0-alpha.5",
"globals": "^17.4.0",
"pug": "^3.0.3",
"sass-embedded": "^1.98.0",
"smol-toml": "^1.6.1",
"stylus": "^0.64.0",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.0"
}
}
+34 -35
View File
@@ -27,37 +27,36 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"arabic-reshaper==3.0.1", # Support for Arabic in reportlab "arabic-reshaper==3.0.0", # 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.1", "cryptography>=47.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.*,>=5.2.17", "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.1.*", "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.5.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.8.*", "djangorestframework==3.16.*",
"djangorestframework==3.17.*",
"dnspython==2.8.*", "dnspython==2.8.*",
"drf_ujson2==1.7.*", "drf_ujson2==1.7.*",
"geoip2==5.*", "geoip2==5.*",
@@ -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,16 +74,16 @@ dependencies = [
"packaging", "packaging",
"paypalrestsdk==1.13.*", "paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*", "paypal-checkout-serversdk==1.0.*",
"PyJWT==2.14.*", "PyJWT==2.12.*",
"phonenumberslite==9.0.*", "phonenumberslite==9.0.*",
"Pillow==12.3.*", "Pillow==12.2.*",
"pretix-plugin-build", "pretix-plugin-build",
"protobuf==7.36.*", "protobuf==7.34.*",
"psycopg2-binary", "psycopg2-binary",
"pycountry", "pycountry",
"pycparser==3.0", "pycparser==3.0",
"pycryptodome==3.23.*", "pycryptodome==3.23.*",
"pypdf==6.19.*", "pypdf==6.5.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab "python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*", "python-dateutil==2.9.*",
"pytz", "pytz",
@@ -92,42 +91,40 @@ dependencies = [
"pyuca", "pyuca",
"qrcode==8.2", "qrcode==8.2",
"redis==7.4.*", "redis==7.4.*",
"reportlab==5.0.*", "reportlab==4.4.*",
"requests==2.34.*", "requests==2.32.*",
"sentry-sdk==2.69.*", "sentry-sdk==2.58.*",
"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.38.*", "fakeredis==2.34.*",
"flake8==7.3.*", "flake8==7.3.*",
"freezegun", "freezegun",
"isort==9.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.*",
"pytest-mock==3.15.*", "pytest-mock==3.15.*",
"pytest-sugar", "pytest-sugar",
"pytest-xdist==3.8.*", "pytest-xdist==3.8.*",
"pytest-playwright", "pytest==9.0.*",
"pytest==9.1.*",
"playwright",
"responses", "responses",
] ]
@@ -140,6 +137,8 @@ build-backend = "backend"
backend-path = ["_build"] backend-path = ["_build"]
requires = [ requires = [
"setuptools", "setuptools",
"setuptools-rust",
"wheel",
"importlib_metadata", "importlib_metadata",
"tomli", "tomli",
] ]
+1 -5
View File
@@ -26,6 +26,7 @@ ignore =
src/tests/plugins/* src/tests/plugins/*
src/tests/plugins/badges/* src/tests/plugins/badges/*
src/tests/plugins/banktransfer/* src/tests/plugins/banktransfer/*
src/tests/plugins/paypal/*
src/tests/plugins/paypal2/* src/tests/plugins/paypal2/*
src/tests/plugins/pretixdroid/* src/tests/plugins/pretixdroid/*
src/tests/plugins/stripe/* src/tests/plugins/stripe/*
@@ -36,9 +37,4 @@ ignore =
CONTRIBUTING.md CONTRIBUTING.md
Dockerfile Dockerfile
SECURITY.md SECURITY.md
eslint.config.mjs
package-lock.json
package.json
tsconfig.json
vite.config.js
+8 -8
View File
@@ -6,13 +6,13 @@ 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: jsi18n
./manage.py collectstatic --noinput ./manage.py collectstatic --noinput
compress: compress: npminstall
./manage.py compress ./manage.py compress
jsi18n: localecompile jsi18n: localecompile
@@ -25,8 +25,8 @@ coverage:
coverage run -m py.test coverage run -m py.test
npminstall: npminstall:
npm ci # keep this in sync with pretix/_build.py!
mkdir -p pretix/static.dist/node_prefix/
npmbuild: cp -r pretix/static/npm_dir/* pretix/static.dist/node_prefix/
npm run build npm ci --prefix=pretix/static.dist/node_prefix
+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.4.2"
+3 -6
View File
@@ -37,11 +37,9 @@ INSTALLED_APPS = [
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.humanize',
# pretix needs to go before staticfiles
# so we can override the runserver command
'pretix.base',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.humanize',
'pretix.base',
'pretix.control', 'pretix.control',
'pretix.presale', 'pretix.presale',
'pretix.multidomain', 'pretix.multidomain',
@@ -104,7 +102,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 +116,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')),
] ]
@@ -247,6 +243,7 @@ STORAGES = {
COMPRESS_PRECOMPILERS = ( COMPRESS_PRECOMPILERS = (
('text/x-scss', 'django_libsass.SassCompiler'), ('text/x-scss', 'django_libsass.SassCompiler'),
('text/vue', 'pretix.helpers.compressor.VueCompiler'),
) )
COMPRESS_OFFLINE_CONTEXT = { COMPRESS_OFFLINE_CONTEXT = {
+6 -7
View File
@@ -21,13 +21,13 @@
# #
import os import os
import shutil
import subprocess import subprocess
from setuptools.command.build import build from setuptools.command.build import build
from setuptools.command.build_ext import build_ext from setuptools.command.build_ext import build_ext
here = os.path.abspath(os.path.dirname(__file__)) here = os.path.abspath(os.path.dirname(__file__))
project_root = os.path.abspath(os.path.join(here, '..', '..'))
npm_installed = False npm_installed = False
@@ -35,14 +35,14 @@ def npm_install():
global npm_installed global npm_installed
if not npm_installed: if not npm_installed:
subprocess.check_call('npm ci', shell=True, cwd=project_root) # keep this in sync with Makefile!
node_prefix = os.path.join(here, 'static.dist', 'node_prefix')
os.makedirs(node_prefix, exist_ok=True)
shutil.copytree(os.path.join(here, 'static', 'npm_dir'), node_prefix, dirs_exist_ok=True)
subprocess.check_call('npm ci', shell=True, cwd=node_prefix)
npm_installed = True npm_installed = True
def npm_build():
subprocess.check_call('npm run build', shell=True, cwd=project_root)
class CustomBuild(build): class CustomBuild(build):
def run(self): def run(self):
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"): if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
@@ -62,7 +62,6 @@ class CustomBuild(build):
settings.COMPRESS_OFFLINE = True settings.COMPRESS_OFFLINE = True
npm_install() npm_install()
npm_build()
management.call_command('compilemessages', verbosity=1) management.call_command('compilemessages', verbosity=1)
management.call_command('compilejsi18n', verbosity=1) management.call_command('compilejsi18n', verbosity=1)
management.call_command('collectstatic', verbosity=1, interactive=False) management.call_command('collectstatic', verbosity=1, interactive=False)
-2
View File
@@ -47,5 +47,3 @@ HAS_MEMCACHED = False
HAS_CELERY = False HAS_CELERY = False
HAS_GEOIP = False HAS_GEOIP = False
SENTRY_ENABLED = False SENTRY_ENABLED = False
VITE_DEV_MODE = False
VITE_IGNORE = False
+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.annul'),
('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.annul'),
('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.annul'),
('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)
+5 -14
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
@@ -702,12 +701,8 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
return super().save(**kwargs) return super().save(**kwargs)
def validate_default(self, value): def validate_default(self, value):
if not value: if not value and self.instance.default:
if self.instance: raise ValidationError("You can't remove the default property, instead set it on another tax rule.")
if self.instance.default:
raise ValidationError("You can't remove the default property, instead set it on another tax rule.")
elif not self.context["event"].tax_rules.exists():
raise ValidationError("You can't remove the default property as there is only one tax rule.")
return value return value
@@ -752,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',
@@ -877,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',
@@ -892,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',
@@ -978,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',
+20 -26
View File
@@ -133,43 +133,37 @@ class JobRunSerializer(serializers.Serializer):
return not bool(self._errors) return not bool(self._errors)
class ExportFormDataField(serializers.Field):
def get_attribute(self, instance):
return (instance.export_identifier, instance.export_form_data)
def to_representation(self, value):
export_identifier, export_form_data = value
exporter = self.context['exporters'].get(export_identifier)
if exporter:
return JobRunSerializer(exporter=exporter).to_representation(export_form_data)
else:
return export_form_data
def get_value(self, dictionary):
return dictionary
def to_internal_value(self, data):
if "export_form_data" in data:
identifier = data.get('export_identifier', self.parent.instance.export_identifier if self.parent.instance else None)
exporter = self.context['exporters'].get(identifier)
if exporter:
return JobRunSerializer(exporter=exporter).to_internal_value(data["export_form_data"])
else:
return data['export_form_data']
class ScheduledExportSerializer(serializers.ModelSerializer): class ScheduledExportSerializer(serializers.ModelSerializer):
schedule_next_run = serializers.DateTimeField(read_only=True) schedule_next_run = serializers.DateTimeField(read_only=True)
export_identifier = serializers.ChoiceField(choices=[]) export_identifier = serializers.ChoiceField(choices=[])
locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en') locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en')
owner = serializers.SlugRelatedField(slug_field='email', read_only=True) owner = serializers.SlugRelatedField(slug_field='email', read_only=True)
error_counter = serializers.IntegerField(read_only=True) error_counter = serializers.IntegerField(read_only=True)
export_form_data = ExportFormDataField()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['export_identifier'].choices = [(e, e) for e in self.context['exporters']] self.fields['export_identifier'].choices = [(e, e) for e in self.context['exporters']]
def validate(self, attrs):
if attrs.get("export_form_data"):
identifier = attrs.get('export_identifier', self.instance.export_identifier if self.instance else None)
exporter = self.context['exporters'].get(identifier)
if exporter:
try:
attrs["export_form_data"] = JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"])
except ValidationError as e:
raise ValidationError({"export_form_data": e.detail})
else:
raise ValidationError({"export_identifier": ["Unknown exporter."]})
return attrs
def to_representation(self, instance):
repr = super().to_representation(instance)
exporter = self.context['exporters'].get(instance.export_identifier)
if exporter:
repr["export_form_data"] = JobRunSerializer(exporter=exporter).to_representation(repr["export_form_data"])
return repr
def validate_mail_additional_recipients(self, value): def validate_mail_additional_recipients(self, value):
d = value.replace(' ', '') d = value.replace(' ', '')
if len(d.split(',')) > 25: if len(d.split(',')) > 25:
+2 -2
View File
@@ -115,10 +115,10 @@ class PluginsField(serializers.Field):
def to_representation(self, obj): def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins from pretix.base.plugins import get_all_plugins
active_plugins = set(obj.get_plugins())
return sorted([ return sorted([
p.module for p in get_all_plugins() p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in active_plugins if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
]) ])
def to_internal_value(self, data): def to_internal_value(self, data):
-6
View File
@@ -45,12 +45,6 @@ class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
return value return value
return super().to_representation(value) return super().to_representation(value)
def to_internal_value(self, data):
value = super().to_internal_value(data)
if value is not None:
return value.pk
return value
class FormFieldWrapperField(serializers.Field): class FormFieldWrapperField(serializers.Field):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
+8 -10
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
@@ -193,7 +191,7 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
class InlineItemProgramTimeSerializer(serializers.ModelSerializer): class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ItemProgramTime model = ItemProgramTime
fields = ('start', 'end', 'location') fields = ('start', 'end')
class ItemBundleSerializer(serializers.ModelSerializer): class ItemBundleSerializer(serializers.ModelSerializer):
@@ -224,7 +222,7 @@ class ItemBundleSerializer(serializers.ModelSerializer):
class ItemProgramTimeSerializer(serializers.ModelSerializer): class ItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ItemProgramTime model = ItemProgramTime
fields = ('id', 'start', 'end', 'location') fields = ('id', 'start', 'end')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
@@ -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'
@@ -550,7 +548,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values', 'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min', 'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max', 'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait') 'valid_string_length_max', 'valid_file_portrait')
def validate_identifier(self, value): def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance) Question._clean_identifier(self.context['event'], value, self.instance)
@@ -619,7 +617,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
options_data = validated_data.pop('options') if 'options' in validated_data else [] options_data = validated_data.pop('options') if 'options' in validated_data else []
items = validated_data.pop('items', []) items = validated_data.pop('items', [])
question = Question.objects.create(**validated_data, container_type=Question.ContainerType.ORDERPOSITION) question = Question.objects.create(**validated_data)
question.items.set(items) question.items.set(items)
for opt_data in options_data: for opt_data in options_data:
QuestionOption.objects.create(question=question, **opt_data) QuestionOption.objects.create(question=question, **opt_data)
+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',
+14 -35
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
@@ -1422,7 +1416,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
qa = QuotaAvailability() qa = QuotaAvailability()
qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0]) qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0])
qa.compute() qa.compute()
v_avail = {}
# These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental # These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental
# use further down # use further down
@@ -1452,13 +1445,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
voucher_usage[v] += 1 voucher_usage[v] += 1
if voucher_usage[v] > 0: if voucher_usage[v] > 0:
if v not in v_avail: redeemed_in_carts = CartPosition.objects.filter(
v.refresh_from_db(fields=['redeemed']) Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
redeemed_in_carts = CartPosition.objects.filter( ).exclude(pk__in=[cp.pk for cp in delete_cps])
Q(voucher=v) & Q(event=self.context['event']) & Q(expires__gte=now_dt) v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
).exclude(pk__in=[cp.pk for cp in delete_cps]) if v_avail < voucher_usage[v]:
v_avail[v] = v.max_usages - v.redeemed - redeemed_in_carts.count()
if v_avail[v] < voucher_usage[v]:
errs[i]['voucher'] = [ errs[i]['voucher'] = [
'The voucher has already been used the maximum number of times.' 'The voucher has already been used the maximum number of times.'
] ]
@@ -1594,7 +1585,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 +1700,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 +1897,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 +1980,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
+9 -102
View File
@@ -27,8 +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 i18nfield.rest_framework import I18nField
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@@ -41,10 +40,9 @@ from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.auth import get_auth_backends from pretix.base.auth import get_auth_backends
from pretix.base.i18n import get_language_without_region from pretix.base.i18n import get_language_without_region
from pretix.base.models import ( from pretix.base.models import (
Customer, Device, EventMetaProperty, GiftCard, GiftCardAcceptance, Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction,
GiftCardTransaction, Membership, MembershipType, OrderPosition, Organizer, Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
ReusableMedium, SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
User,
) )
from pretix.base.models.seating import SeatingPlanLayoutValidator from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.permissions import ( from pretix.base.permissions import (
@@ -60,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__)
@@ -73,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
@@ -428,8 +426,6 @@ class TeamSerializer(serializers.ModelSerializer):
for k, v in OLD_TO_NEW_ORGANIZER_MIGRATION.items(): for k, v in OLD_TO_NEW_ORGANIZER_MIGRATION.items():
if full_data.get(k) is True: if full_data.get(k) is True:
data["limit_organizer_permissions"].update({kk: True for kk in v}) data["limit_organizer_permissions"].update({kk: True for kk in v})
for key in list(k for k in data if k.startswith("can_")):
del data[key]
if full_data.get('limit_events') and full_data.get('all_events'): if full_data.get('limit_events') and full_data.get('all_events'):
raise ValidationError('Do not set both limit_events and all_events.') raise ValidationError('Do not set both limit_events and all_events.')
@@ -496,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
}) })
}, },
@@ -580,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',
@@ -612,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',
@@ -642,88 +634,3 @@ class OrganizerSettingsSerializer(SettingsSerializer):
) )
# TODO: make sure pub is always correct # TODO: make sure pub is always correct
return 'pub/' + fname return 'pub/' + fname
class MetaPropertyListField(serializers.ListField):
def __init__(self, *args, **kwargs):
kwargs["validators"] = kwargs.pop("validators", [])
def validate_keys_unique(choices):
if not choices:
return
keys = [c.get("key") for c in choices]
if len(set(keys)) < len(keys):
raise ValidationError("The key for each meta property value option must be unique.")
kwargs["validators"].append(
validate_keys_unique
)
super().__init__(*args, **kwargs)
class MetaPropertyDictField(serializers.DictField):
def __init__(self, **kwargs):
self.label_child = kwargs.pop("label_child", I18nField())
super().__init__(**kwargs)
def to_representation(self, value):
# django added unneccessary keys DELETE, ORDER through formsets, filter them here for backwards compat
d = {
"key": value["key"]
}
if "label" in value:
d["label"] = self.label_child.to_representation(value["label"])
return super().to_representation(d)
def to_internal_value(self, data):
if not isinstance(data, dict):
raise ValidationError("Meta property value options must be a dict.")
if not isinstance(data.get("key"), str):
raise ValidationError("Meta property value options must have a key of type string.")
if any(k not in {"key", "label"} for k in data.keys()):
raise ValidationError("Meta property value options may only have a key and optionally a label.")
if "label" in data:
try:
data["label"] = self.label_child.to_internal_value(data["label"])
except ValidationError as e:
raise ValidationError({"label": e.detail})
return super().to_internal_value(data)
class EventMetaPropertiesSerializer(I18nAwareModelSerializer):
choices = MetaPropertyListField(
child=MetaPropertyDictField(
label_child=I18nField()
),
allow_null=True,
)
class Meta:
model = EventMetaProperty
fields = (
'id', 'name', 'default', 'required', 'protected', 'filter_public', 'public_label', 'filter_allowed',
'choices'
)
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
choices = full_data.get("choices")
default = full_data.get("default")
if choices and default:
choice_keys = [c.get("key") for c in choices]
if default not in choice_keys:
raise ValidationError("You cannot set a default value that is not a valid value.")
if not choices and "choices" in data:
# normalize empty dict to None
data["choices"] = None
return data
-1
View File
@@ -68,7 +68,6 @@ orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportVie
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters') orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
orga_router.register(r'transactions', order.OrganizerTransactionViewSet) orga_router.register(r'transactions', order.OrganizerTransactionViewSet)
orga_router.register(r'orderpositions', order.OrganizerOrderPositionViewSet, basename='orderpositions') orga_router.register(r'orderpositions', order.OrganizerOrderPositionViewSet, basename='orderpositions')
orga_router.register(r'event_meta_properties', organizer.EventMetaPropertiesViewSet)
team_router = routers.DefaultRouter() team_router = routers.DefaultRouter()
team_router.register(r'members', organizer.TeamMemberViewSet) team_router.register(r'members', organizer.TeamMemberViewSet)
+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',
+1 -32
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
@@ -475,12 +456,8 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
write_permission = 'event.items:write' write_permission = 'event.items:write'
def get_queryset(self): def get_queryset(self):
return self.request.event.questions.filter( return self.request.event.questions.prefetch_related('options').all()
# the container_type parameter is undocumented, this API is going to change in a later release
container_type=self.request.GET.get('container_type', Question.ContainerType.ORDERPOSITION),
).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(
@@ -495,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(
@@ -505,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',
@@ -534,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)
@@ -545,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(
@@ -555,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',
@@ -614,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(
@@ -637,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
@@ -693,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:
+4 -54
View File
@@ -44,16 +44,15 @@ from pretix.api.models import OAuthAccessToken
from pretix.api.pagination import TotalOrderingFilter from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.organizer import ( from pretix.api.serializers.organizer import (
CustomerCreateSerializer, CustomerSerializer, DeviceSerializer, CustomerCreateSerializer, CustomerSerializer, DeviceSerializer,
EventMetaPropertiesSerializer, GiftCardSerializer, GiftCardSerializer, GiftCardTransactionSerializer, MembershipSerializer,
GiftCardTransactionSerializer, MembershipSerializer,
MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer, MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer,
SalesChannelSerializer, SeatingPlanSerializer, TeamAPITokenSerializer, SalesChannelSerializer, SeatingPlanSerializer, TeamAPITokenSerializer,
TeamInviteSerializer, TeamMemberSerializer, TeamSerializer, TeamInviteSerializer, TeamMemberSerializer, TeamSerializer,
) )
from pretix.base.models import ( from pretix.base.models import (
Customer, Device, Event, EventMetaProperty, GiftCard, GiftCardTransaction, Customer, Device, Event, GiftCard, GiftCardTransaction, LogEntry,
LogEntry, Membership, MembershipType, Organizer, SalesChannel, SeatingPlan, Membership, MembershipType, Organizer, SalesChannel, SeatingPlan, Team,
Team, TeamAPIToken, TeamInvite, User, TeamAPIToken, TeamInvite, User,
) )
from pretix.base.plugins import ( from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
@@ -395,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()
@@ -695,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.")
@@ -836,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.")
@@ -847,49 +843,3 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
data={'id': instance.pk} data={'id': instance.pk}
) )
instance.delete() instance.delete()
class EventMetaPropertiesViewSet(viewsets.ModelViewSet):
serializer_class = EventMetaPropertiesSerializer
queryset = EventMetaProperty.objects.none()
write_permission = 'organizer.settings.general:write'
def get_queryset(self):
return self.request.organizer.meta_properties.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action(
'pretix.property.deleted',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk}
)
instance.delete()
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(organizer_id=self.request.organizer.pk)
serializer.instance.log_action(
'pretix.property.created',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save(organizer_id=self.request.organizer.pk)
serializer.instance.log_action(
'pretix.property.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
-4
View File
@@ -40,7 +40,6 @@ with scopes_disabled():
class VoucherFilter(FilterSet): class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active') active = BooleanFilter(method='filter_active')
code = CharFilter(lookup_expr='iexact') code = CharFilter(lookup_expr='iexact')
search = CharFilter(method='search_qs')
class Meta: class Meta:
model = Voucher model = Voucher
@@ -55,9 +54,6 @@ with scopes_disabled():
return queryset.filter(Q(redeemed__gte=F('max_usages')) | return queryset.filter(Q(redeemed__gte=F('max_usages')) |
(Q(valid_until__isnull=False) & Q(valid_until__lte=now()))) (Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
def search_qs(self, qs, name, value):
return qs.filter(Q(code__icontains=value) | Q(tag__icontains=value) | Q(comment__icontains=value))
class VoucherViewSet(viewsets.ModelViewSet): class VoucherViewSet(viewsets.ModelViewSet):
serializer_class = VoucherSerializer serializer_class = VoucherSerializer
-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',
-6
View File
@@ -408,12 +408,6 @@ def register_default_webhook_events(sender, **kwargs):
_('This includes product added or deleted and changes to nested objects like ' _('This includes product added or deleted and changes to nested objects like '
'variations or bundles.'), 'variations or bundles.'),
), ),
ParametrizedItemWebhookEvent(
'pretix.event.quota.*',
_('Quota changed'),
_('This includes related events like creation, deletion, opening or closing of quotas. '
'No webhook is sent for changes to the resulting availability.'),
),
ParametrizedEventWebhookEvent( ParametrizedEventWebhookEvent(
'pretix.event.live.activated', 'pretix.event.live.activated',
_('Shop taken live'), _('Shop taken live'),
+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 -7
View File
@@ -27,7 +27,7 @@ from datetime import timedelta
from functools import cached_property from functools import cached_property
from typing import List, Optional, Protocol from typing import List, Optional, Protocol
from django.conf import settings import sentry_sdk
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -236,9 +236,7 @@ class OutboundSyncProvider:
# model changes saved by set_sync_error / clear_in_flight calls below # model changes saved by set_sync_error / clear_in_flight calls below
if sq.failed_attempts >= self.max_attempts: if sq.failed_attempts >= self.max_attempts:
logger.exception('Failed to sync order (max attempts exceeded)') logger.exception('Failed to sync order (max attempts exceeded)')
if settings.SENTRY_ENABLED: sentry_sdk.capture_exception(e)
import sentry_sdk
sentry_sdk.capture_exception(e)
sq.set_sync_error("exceeded", e.messages, e.full_message) sq.set_sync_error("exceeded", e.messages, e.full_message)
else: else:
logger.info( logger.info(
@@ -249,9 +247,7 @@ class OutboundSyncProvider:
sq.clear_in_flight() sq.clear_in_flight()
except Exception as e: except Exception as e:
logger.exception('Failed to sync order (unhandled exception)') logger.exception('Failed to sync order (unhandled exception)')
if settings.SENTRY_ENABLED: sentry_sdk.capture_exception(e)
import sentry_sdk
sentry_sdk.capture_exception(e)
sq.set_sync_error("internal", [], str(e)) sq.set_sync_error("internal", [], str(e))
@cached_property @cached_property
+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)
+4 -27
View File
@@ -54,7 +54,6 @@ 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 ..exporter import BaseExporter, MultiSheetListExporter from ..exporter import BaseExporter, MultiSheetListExporter
from ..invoicing.transmission import get_transmission_types
from ..services.export import ExportError from ..services.export import ExportError
from ..services.invoices import invoice_pdf_task from ..services.invoices import invoice_pdf_task
from ..signals import ( from ..signals import (
@@ -198,7 +197,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
def iterate_sheet(self, form_data, sheet): def iterate_sheet(self, form_data, sheet):
_ = gettext _ = gettext
if sheet == 'invoices': if sheet == 'invoices':
headers = [ yield [
_('Invoice number'), _('Invoice number'),
_('Date'), _('Date'),
_('Order code'), _('Order code'),
@@ -231,18 +230,8 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Total value (without taxes)'), _('Total value (without taxes)'),
_('Payment matching IDs'), _('Payment matching IDs'),
_('Payment providers'), _('Payment providers'),
_('Transmission type'),
_('Transmission status'),
_('Transmission date'),
] ]
transmission_types = get_transmission_types()
for tt in transmission_types:
for c in tt.describe_info_columns():
headers.append(str(tt.verbose_name) + ': ' + str(c))
yield headers
p_providers = OrderPayment.objects.filter( p_providers = OrderPayment.objects.filter(
order=OuterRef('order'), order=OuterRef('order'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED, state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
@@ -253,7 +242,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
'm' 'm'
).order_by() ).order_by()
base_qs = self.invoices_queryset(form_data) base_qs = self.invoices_queryset(form_data)\
qs = base_qs.select_related( qs = base_qs.select_related(
'order', 'refers' 'order', 'refers'
@@ -291,7 +280,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
if mid: if mid:
pmis.append(mid) pmis.append(mid)
pmi = '\n'.join(pmis) pmi = '\n'.join(pmis)
line = [ yield [
i.full_invoice_no, i.full_invoice_no,
date_format(i.date, "SHORT_DATE_FORMAT"), date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code, i.order.code,
@@ -326,20 +315,8 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
', '.join([ ', '.join([
str(self.providers.get(p, p)) for p in sorted(set((i.payment_providers or '').split(','))) str(self.providers.get(p, p)) for p in sorted(set((i.payment_providers or '').split(',')))
if p and p != 'free' if p and p != 'free'
]), ])
i.transmission_type_instance.verbose_name,
i.get_transmission_status_display(),
date_format(i.transmission_date, "SHORT_DATETIME_FORMAT") if i.transmission_date else "",
] ]
for tt in transmission_types:
if tt.identifier == i.transmission_type:
described = dict(tt.describe_info(i.invoice_to_transmission_info, i.invoice_to_country, i.invoice_to_is_business))
for c in tt.describe_info_columns():
line.append(described.get(c, ""))
else:
for c in tt.describe_info_columns():
line.append("")
yield line
elif sheet == 'lines': elif sheet == 'lines':
yield [ yield [
_('Invoice number'), _('Invoice number'),
+5 -6
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,
) )
@@ -160,7 +160,7 @@ class OrderListExporter(MultiSheetListExporter):
def _get_all_payment_methods(self, qs): def _get_all_payment_methods(self, qs):
pps = dict(get_all_payment_providers()) pps = dict(get_all_payment_providers())
return sorted([(pp, pps.get(pp, pp)) for pp in set( return sorted([(pp, pps[pp]) for pp in set(
OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list( OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list(
'provider', flat=True 'provider', flat=True
).distinct() ).distinct()
@@ -330,7 +330,6 @@ class OrderListExporter(MultiSheetListExporter):
taxsum=Sum('tax_value'), grosssum=Sum('value') taxsum=Sum('tax_value'), grosssum=Sum('value')
) )
} }
payment_methods = None
if form_data.get('include_payment_amounts'): if form_data.get('include_payment_amounts'):
payment_sum_cache = { payment_sum_cache = {
(o['order__id'], o['provider']): o['grosssum'] for o in (o['order__id'], o['provider']): o['grosssum'] for o in
@@ -348,7 +347,6 @@ class OrderListExporter(MultiSheetListExporter):
grosssum=Sum('amount') grosssum=Sum('amount')
) )
} }
payment_methods = self._get_all_payment_methods(qs)
sum_cache = { sum_cache = {
(o['order__id'], o['tax_rate']): o for o in (o['order__id'], o['tax_rate']): o for o in
OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate( OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate(
@@ -429,13 +427,14 @@ 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,
}) })
) )
if form_data.get('include_payment_amounts'): if form_data.get('include_payment_amounts'):
payment_methods = self._get_all_payment_methods(qs)
for id, vn in payment_methods: for id, vn in payment_methods:
row.append( row.append(
payment_sum_cache.get((order.id, id), Decimal('0.00')) - payment_sum_cache.get((order.id, id), Decimal('0.00')) -
@@ -855,7 +854,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'
File diff suppressed because it is too large Load Diff
+13 -24
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):
@@ -82,8 +82,8 @@ class UserSettingsForm(forms.ModelForm):
class User2FADeviceAddForm(forms.Form): class User2FADeviceAddForm(forms.Form):
name = forms.CharField(label=_('Device name'), max_length=64) name = forms.CharField(label=_('Device name'), max_length=64)
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=( devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
('otp_totp.totpdevice', _('Smartphone with the Authenticator application')), ('totp', _('Smartphone with the Authenticator application')),
('pretixbase.webauthndevice', _('WebAuthn-compatible hardware token (e.g. Yubikey)')), ('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
)) ))
@@ -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
+23 -74
View File
@@ -43,11 +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,
)
from pretix.helpers.safedownload import get_token
def replace_arabic_numbers(inp): def replace_arabic_numbers(inp):
@@ -113,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']
@@ -122,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)
@@ -158,26 +134,36 @@ class TimePickerWidget(forms.TimeInput):
class UploadedFileWidget(forms.ClearableFileInput): class UploadedFileWidget(forms.ClearableFileInput):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.position = kwargs.pop('position')
self.event = kwargs.pop('event')
self.answer = kwargs.pop('answer') self.answer = kwargs.pop('answer')
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
class FakeFile: class FakeFile:
def __init__(self, file, answer, request): def __init__(self, file, position, event, answer):
self.file = file self.file = file
self.position = position
self.event = event
self.answer = answer self.answer = answer
self.request = request
def __str__(self): def __str__(self):
return os.path.basename(self.file.name).split('.', 1)[-1] return os.path.basename(self.file.name).split('.', 1)[-1]
@property @property
def url(self): def url(self):
token = get_token(self.request, self.answer) from pretix.base.models import OrderPosition
if self.request.resolver_match.namespace == 'control': from pretix.multidomain.urlreverse import eventreverse
return self.answer.backend_file_url + '?token=' + token
if isinstance(self.position, OrderPosition):
return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={
'order': self.position.order.code,
'secret': self.position.order.secret,
'answer': self.answer.pk,
})
else: else:
return self.answer.frontend_file_url + '?token=' + token return eventreverse(self.event, 'presale:event.cart.download.answer', kwargs={
'answer': self.answer.pk,
})
def get_context(self, name, value, attrs): def get_context(self, name, value, attrs):
# Browsers can't recognize that the server already has a file uploaded # Browsers can't recognize that the server already has a file uploaded
@@ -190,13 +176,13 @@ class UploadedFileWidget(forms.ClearableFileInput):
def format_value(self, value): def format_value(self, value):
if self.is_initial(value): if self.is_initial(value):
return self.FakeFile(value, self.answer, self.request) return self.FakeFile(value, self.position, self.event, self.answer)
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']
@@ -219,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)
@@ -305,18 +269,3 @@ class BusinessBooleanRadio(forms.RadioSelect):
'False': False, 'False': False,
False: False, False: False,
}.get(value) }.get(value)
class OptionAttrsSelect(forms.Select):
def __init__(self, *args, option_attrs=None, **kwargs):
super().__init__(*args, **kwargs)
self.option_attrs = option_attrs or {}
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
option = super().create_option(
name, value, label, selected, index, subindex=subindex, attrs=attrs
)
extra = self.option_attrs.get(str(value))
if extra:
option["attrs"].update(extra)
return option
+91 -77
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']
)) ))
@@ -1149,10 +1160,13 @@ class Modern1Renderer(ClassicInvoiceRenderer):
return stylesheet return stylesheet
def _draw_invoice_from(self, canvas): def _draw_invoice_from(self, canvas):
if not self.invoice.address_invoice_from: if not self.invoice.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(),
), ),
@@ -107,9 +107,6 @@ class TransmissionType:
def transmission_info_to_form_data(self, transmission_info: dict) -> dict: def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return transmission_info return transmission_info
def describe_info_columns(self):
return [f.label for f in self.invoice_address_form_fields.values()]
def describe_info(self, transmission_info: dict, country: Country, is_business: bool): def describe_info(self, transmission_info: dict, country: Country, is_business: bool):
form_data = self.transmission_info_to_form_data(transmission_info) form_data = self.transmission_info_to_form_data(transmission_info)
data = [] data = []
@@ -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
@@ -1,60 +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/>.
#
"""This command supersedes the Django-inbuilt runserver command.
It runs the local frontend server, if node is installed and the setting
is set.
"""
import atexit
import os
import subprocess
from pathlib import Path
from django.conf import settings
from django.contrib.staticfiles.management.commands.runserver import (
Command as Parent,
)
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
class Command(Parent):
def handle(self, *args, **options):
# Only start Vite in the non-main process of the autoreloader
if settings.VITE_DEV_MODE and os.environ.get(DJANGO_AUTORELOAD_ENV) != "true":
# Start the vite server in the background
vite_server = subprocess.Popen(
["npm", "run", "dev:control"],
cwd=Path(__file__).parent.parent.parent.parent.parent,
stdin=subprocess.DEVNULL
)
def cleanup():
vite_server.terminate()
try:
vite_server.wait(timeout=5)
except subprocess.TimeoutExpired:
vite_server.kill()
atexit.register(cleanup)
super().handle(*args, **options)
+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)
+55 -204
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,34 +266,28 @@ 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}"],
'script-src': ["{static}"], 'script-src': ['{static}'],
'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
@@ -404,19 +295,6 @@ class SecurityMiddleware(MiddlewareMixin):
# this. However, we'll restrict it to HTTPS. # this. However, we'll restrict it to HTTPS.
'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:
h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"]
h['style-src'] += ["'unsafe-inline'"]
h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"]
# 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 +303,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 +334,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 +356,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,19 +0,0 @@
# Generated by Django 4.2.27 on 2026-01-21 12:06
import i18nfield.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0298_pluggable_permissions"),
]
operations = [
migrations.AddField(
model_name="itemprogramtime",
name="location",
field=i18nfield.fields.I18nTextField(max_length=200, null=True),
)
]
@@ -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",
),
),
]
@@ -1,63 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-01 20:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0307_devicelastseen"),
]
operations = [
migrations.CreateModel(
name="CheckoutSession",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("cart_id", models.CharField(max_length=255, unique=True)),
("created", models.DateTimeField(auto_now_add=True)),
("testmode", models.BooleanField(default=False)),
("session_data", models.JSONField(default=dict)),
(
"customer",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="checkout_sessions",
to="pretixbase.customer",
),
),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="checkout_sessions",
to="pretixbase.event",
),
),
(
"sales_channel",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.saleschannel",
),
),
],
),
migrations.AddField(
model_name="invoiceaddress",
name="checkout_session",
field=models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="invoice_address",
to="pretixbase.checkoutsession",
),
),
]
@@ -1,33 +0,0 @@
# Generated by Django 5.2.15 on 2026-08-07 20:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0308_checkoutsession_invoiceaddress_checkout_session'),
]
operations = [
migrations.AddField(
model_name='question',
name='container_type',
field=models.CharField(default='P', max_length=5),
),
migrations.AddField(
model_name='questionanswer',
name='checkoutsession',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.checkoutsession'),
),
migrations.AddField(
model_name='questionanswer',
name='order',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.order'),
),
migrations.AlterUniqueTogether(
name='questionanswer',
unique_together={('cartposition', 'question'), ('checkoutsession', 'question'), ('order', 'question'), ('orderposition', 'question')},
),
]
@@ -1,19 +0,0 @@
# Generated by Django 5.2.12 on 2026-08-19 11:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0309_alter_questionanswer_unique_together_and_more"),
]
operations = [
migrations.AddField(
model_name="question",
name="valid_string_length_min",
field=models.PositiveIntegerField(null=True),
),
]
@@ -1,49 +0,0 @@
# Generated by Django 5.2.17 on 2026-09-21 11:30
import django.db.models.deletion
from django.db import migrations, models
def fix_unshredded_invoices(apps, _):
Invoice = apps.get_model("pretixbase", "Invoice")
InvoiceLine = apps.get_model("pretixbase", "InvoiceLine")
ignore_fields = (
# bool/int fields are not listed and skipped automatically
'prefix', 'invoice_no', 'full_invoice_no', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id',
'invoice_from_vat_id', 'locale', 'payment_provider_stamp', 'footer_text', 'foreign_currency_display',
'foreign_currency_source', 'transmission_type', 'transmission_provider', 'transmission_status',
)
for i in Invoice.objects.filter(shredded=True):
for f in Invoice._meta.fields:
if f.name in ignore_fields:
continue
val = getattr(i, f.name, None)
if val and isinstance(val, str):
setattr(i, f.name, "█")
elif val and isinstance(val, list): # jsonfield
setattr(i, f.name, [])
elif val and isinstance(val, dict): # jsonfield
setattr(i, f.name, {"_shredded": True})
i.save()
InvoiceLine.objects.filter(
attendee_name__isnull=False,
invoice__shredded=True
).update(attendee_name="█")
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0310_question_valid_string_length_min"),
]
operations = [
migrations.RunPython(
fix_unshredded_invoices,
migrations.RunPython.noop,
),
]
+1 -1
View File
@@ -442,7 +442,7 @@ class AttendeeState(ImportColumn):
@property @property
def verbose_name(self): def verbose_name(self):
return _('Attendee address') + ': ' + pgettext('address', 'State') return _('Attendee address') + ': ' + _('State')
def clean(self, value, previous_values): def clean(self, value, previous_values):
if value: if value:

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