mirror of
https://github.com/pretix/pretix.git
synced 2026-06-26 03:46:14 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4402987b8a | |||
| 06278acd0a |
@@ -1,6 +1,5 @@
|
||||
doc/
|
||||
env/
|
||||
node_modules/
|
||||
res/
|
||||
local/
|
||||
.git/
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
name: Packaging
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.13"]
|
||||
python-version: ["3.11"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -46,7 +46,4 @@ jobs:
|
||||
- name: Run build
|
||||
run: python -m build
|
||||
- name: Check files
|
||||
run: |
|
||||
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
|
||||
run: unzip -l dist/pretix*whl | grep node_modules || exit 1
|
||||
|
||||
@@ -1,53 +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"
|
||||
- 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 node dependencies
|
||||
run: sudo npm install --global @cyclonedx/cyclonedx-npm
|
||||
- name: Install CycloneDX CLI
|
||||
run: wget https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.32.0/cyclonedx-linux-x64 && chmod +x cyclonedx-linux-x64
|
||||
- name: Install Python dependencies
|
||||
run: pip3 install -U uv cyclonedx-bom prisma-sbom-submit
|
||||
- name: Create empty environment
|
||||
run: uv venv sbom-env
|
||||
- name: Install package
|
||||
run: uv pip install --python ./sbom-env/bin/python .
|
||||
- name: Create Python SBOM
|
||||
run: cyclonedx-py environment sbom-env > sbom-python.json
|
||||
- name: Install node dependencies
|
||||
run: npm ci
|
||||
- name: Create JavaScript SBOM
|
||||
run: cyclonedx-npm > sbom-npm.json
|
||||
- name: Merge SBOMs
|
||||
run: ./cyclonedx-linux-x64 merge --input-files sbom-python.json sbom-npm.json --output-format json --output-file sbom.json
|
||||
- name: Submit SBOM
|
||||
run: prisma-sbom-submit --server https://prisma.pretix.com sbom.json
|
||||
env:
|
||||
PRISMA_UPLOAD_TOKEN: ${{ secrets.PRISMA_UPLOAD_TOKEN }}
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
name: Check gettext syntax
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.13
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
@@ -49,10 +49,10 @@ jobs:
|
||||
name: Spellcheck
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.13
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
|
||||
@@ -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
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.13
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
@@ -44,10 +44,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.13
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
@@ -64,10 +64,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.13
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
python-version: 3.11
|
||||
- name: Install Dependencies
|
||||
run: pip3 install licenseheaders
|
||||
- name: Run licenseheaders
|
||||
|
||||
@@ -23,15 +23,13 @@ jobs:
|
||||
name: Tests
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.13", "3.14"]
|
||||
python-version: ["3.10", "3.11", "3.13"]
|
||||
database: [sqlite, postgres]
|
||||
exclude:
|
||||
- database: sqlite
|
||||
python-version: "3.10"
|
||||
- database: sqlite
|
||||
python-version: "3.11"
|
||||
- database: sqlite
|
||||
python-version: "3.12"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
@@ -72,7 +70,7 @@ jobs:
|
||||
run: make all compress
|
||||
- name: Run tests
|
||||
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
|
||||
working-directory: ./src
|
||||
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reuse-db
|
||||
@@ -83,47 +81,4 @@ jobs:
|
||||
file: src/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
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
|
||||
- name: Run E2E tests
|
||||
working-directory: ./src
|
||||
run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10
|
||||
if: matrix.database == 'postgres' && matrix.python-version == '3.11'
|
||||
|
||||
@@ -24,7 +24,5 @@ local/
|
||||
.project
|
||||
.pydevproject
|
||||
.DS_Store
|
||||
node_modules/
|
||||
.vite/
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -10,9 +10,9 @@ tests:
|
||||
- cd src
|
||||
- python manage.py check
|
||||
- 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:
|
||||
- '/^v.*$/'
|
||||
- pypi
|
||||
pypi:
|
||||
stage: release
|
||||
image:
|
||||
@@ -35,7 +35,7 @@ pypi:
|
||||
- twine check dist/*
|
||||
- twine upload dist/*
|
||||
only:
|
||||
- '/^v.*$/'
|
||||
- pypi
|
||||
artifacts:
|
||||
paths:
|
||||
- src/dist/
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
24
|
||||
17
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/*
|
||||
+6
-10
@@ -1,7 +1,6 @@
|
||||
FROM python:3.13-trixie
|
||||
FROM python:3.11-bookworm
|
||||
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get update && \
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
gettext \
|
||||
@@ -22,7 +21,8 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
libmaxminddb0 \
|
||||
libmaxminddb-dev \
|
||||
zlib1g-dev \
|
||||
nodejs && \
|
||||
nodejs \
|
||||
npm && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
dpkg-reconfigure locales && \
|
||||
@@ -31,7 +31,6 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
mkdir /etc/pretix && \
|
||||
mkdir /data && \
|
||||
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
|
||||
chmod 0755 /pretix && \
|
||||
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
|
||||
mkdir /static && \
|
||||
mkdir /etc/supervisord
|
||||
@@ -50,14 +49,11 @@ COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
|
||||
COPY pyproject.toml /pretix/pyproject.toml
|
||||
COPY _build /pretix/_build
|
||||
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 \
|
||||
pip \
|
||||
setuptools && \
|
||||
setuptools \
|
||||
wheel && \
|
||||
cd /pretix && \
|
||||
PRETIX_DOCKER_BUILD=TRUE pip3 install \
|
||||
-e ".[memcached]" \
|
||||
|
||||
@@ -48,8 +48,3 @@ recursive-include src Makefile
|
||||
recursive-exclude doc *
|
||||
recursive-exclude deployment *
|
||||
recursive-exclude res *
|
||||
|
||||
include package.json
|
||||
include package-lock.json
|
||||
include tsconfig.json
|
||||
include vite.config.ts
|
||||
|
||||
@@ -192,7 +192,7 @@ Cart position endpoints
|
||||
* ``attendee_email`` (optional)
|
||||
* ``subevent`` (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)
|
||||
* ``voucher`` (optional, expect a voucher code)
|
||||
* ``addons`` (optional, expect a list of nested objects of cart positions)
|
||||
|
||||
@@ -46,14 +46,12 @@ Checking a ticket in
|
||||
this request twice with the same nonce, the second request will also succeed but will always
|
||||
create only one check-in object even when the previous request was successful as well. This
|
||||
allows for a certain level of idempotency and enables you to re-try after a connection failure.
|
||||
:<json 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
|
||||
order) when building texts (currently only the ``reason_explanation`` response field).
|
||||
Defaults to ``false`` in which case the server will determine the language (currently
|
||||
the event default language, might change in the future with support for the
|
||||
``Accept-Language`` header).
|
||||
:>json string status: ``"ok"``, ``"incomplete"``, ``"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_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
|
||||
@@ -69,8 +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),
|
||||
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 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"``.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -228,9 +224,6 @@ Checking a ticket in
|
||||
* ``ambiguous`` - Multiple tickets match scan, rejected.
|
||||
* ``revoked`` - Ticket code has been revoked.
|
||||
* ``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.
|
||||
|
||||
In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation``
|
||||
|
||||
@@ -602,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
|
||||
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
|
||||
exchange are only supported on the new API.
|
||||
URL-safe. We recommend to use our new :ref:`check-in API <rest-checkin>` instead.
|
||||
|
||||
: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
|
||||
@@ -742,9 +741,6 @@ Order position endpoints
|
||||
* ``ambiguous`` - Multiple tickets match scan, rejected.
|
||||
* ``revoked`` - Ticket code has been revoked.
|
||||
* ``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``
|
||||
with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.
|
||||
|
||||
@@ -844,187 +844,3 @@ You can also fetch existing leads (if you are authorized to do so):
|
||||
:statuscode 200: No error
|
||||
:statuscode 401: Invalid authentication code
|
||||
: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
|
||||
@@ -16,7 +16,6 @@ Field Type Description
|
||||
id integer Internal ID of the program time
|
||||
start datetime The start date time for this program time slot.
|
||||
end datetime The end date time for this program time slot.
|
||||
location multi-lingual string The program time slot's location (or ``null``)
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: TODO
|
||||
@@ -55,20 +54,17 @@ Endpoints
|
||||
{
|
||||
"id": 2,
|
||||
"start": "2025-08-14T22:00:00Z",
|
||||
"end": "2025-08-15T00:00:00Z",
|
||||
"location": null
|
||||
"end": "2025-08-15T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"start": "2025-08-12T22:00:00Z",
|
||||
"end": "2025-08-13T22:00:00Z",
|
||||
"location": null
|
||||
"end": "2025-08-13T22:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"start": "2025-08-15T22:00:00Z",
|
||||
"end": "2025-08-17T22:00:00Z",
|
||||
"location": null
|
||||
"end": "2025-08-17T22:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -103,8 +99,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"start": "2025-08-15T22:00:00Z",
|
||||
"end": "2025-10-27T23:00:00Z",
|
||||
"location": null
|
||||
"end": "2025-10-27T23:00:00Z"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -130,8 +125,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"start": "2025-08-15T10:00:00Z",
|
||||
"end": "2025-08-15T22:00:00Z",
|
||||
"location": null
|
||||
"end": "2025-08-15T22:00:00Z"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -145,8 +139,7 @@ Endpoints
|
||||
{
|
||||
"id": 17,
|
||||
"start": "2025-08-15T10:00:00Z",
|
||||
"end": "2025-08-15T22:00:00Z",
|
||||
"location": null
|
||||
"end": "2025-08-15T22:00:00Z"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event/item to create a program time for
|
||||
|
||||
@@ -131,7 +131,7 @@ allow_waitinglist boolean If ``false``,
|
||||
product when it is sold out.
|
||||
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).
|
||||
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.
|
||||
show_quota_left boolean Publicly show how many tickets are still available.
|
||||
If this is ``null``, the event default is used.
|
||||
|
||||
@@ -1069,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_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)
|
||||
* ``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)
|
||||
* ``answers``
|
||||
|
||||
|
||||
@@ -21,16 +21,12 @@ id integer Internal ID of
|
||||
type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``.
|
||||
organizer string Organizer slug of the organizer who "owns" this medium.
|
||||
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.
|
||||
created datetime Date of creation
|
||||
updated datetime Date of last modification
|
||||
expires datetime Expiry date (or ``null``)
|
||||
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 **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_orderposition integer Internal ID of a ticket 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
|
||||
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_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
|
||||
---------
|
||||
|
||||
@@ -89,7 +77,6 @@ Endpoints
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderpositions": [],
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
@@ -105,13 +92,10 @@ Endpoints
|
||||
: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 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_giftcard: Only show media linked to the given gift card.
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderpositions"``,
|
||||
``"linked_orderposition"`` (**DEPRECATED**), or ``"customer"``, the respective field will be shown
|
||||
as a nested value instead of just an ID.
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``,
|
||||
or ``"customer"``, the respective field will be shown as a nested value instead of just an ID.
|
||||
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
|
||||
matching easier. The parameter can be given multiple times.
|
||||
@@ -150,7 +134,6 @@ Endpoints
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderpositions": [],
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
@@ -208,7 +191,6 @@ Endpoints
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderpositions": [],
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
@@ -216,9 +198,9 @@ Endpoints
|
||||
}
|
||||
|
||||
: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
|
||||
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
|
||||
can be given multiple times.
|
||||
:statuscode 201: no error
|
||||
@@ -245,7 +227,6 @@ Endpoints
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderpositions": [],
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
@@ -270,7 +251,6 @@ Endpoints
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderpositions": [],
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
@@ -278,7 +258,7 @@ Endpoints
|
||||
}
|
||||
|
||||
: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
|
||||
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
|
||||
@@ -307,7 +287,7 @@ Endpoints
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"linked_orderpositions": [13, 29]
|
||||
"linked_orderposition": 13
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -328,8 +308,7 @@ Endpoints
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderpositions": [13, 29],
|
||||
"linked_orderposition": None,
|
||||
"linked_orderposition": 13,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
"info": {}
|
||||
@@ -337,7 +316,7 @@ Endpoints
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer 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
|
||||
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
|
||||
|
||||
@@ -70,7 +70,6 @@ The following values for ``action_types`` are valid with pretix core:
|
||||
* ``pretix.subevent.changed``
|
||||
* ``pretix.subevent.deleted``
|
||||
* ``pretix.event.item.*``
|
||||
* ``pretix.event.quota.*``
|
||||
* ``pretix.event.live.activated``
|
||||
* ``pretix.event.live.deactivated``
|
||||
* ``pretix.event.testmode.activated``
|
||||
|
||||
@@ -64,8 +64,8 @@ Backend
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
: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_position_buttons, subevent_forms, item_formsets, order_search_filter_q, order_search_forms, subevent_detail_html
|
||||
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms,
|
||||
item_formsets, order_search_filter_q, order_search_forms
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:no-index:
|
||||
|
||||
@@ -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/
|
||||
.. _flake8: https://pypi.python.org/pypi/flake8
|
||||
.. _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/
|
||||
.. _class-based views: https://docs.djangoproject.com/en/6.0/topics/class-based-views/
|
||||
.. _translation: https://docs.djangoproject.com/en/1.11/topics/i18n/translation/
|
||||
.. _class-based views: https://docs.djangoproject.com/en/1.11/topics/class-based-views/
|
||||
.. _pytest-style: https://docs.pytest.org/en/latest/assert.html
|
||||
.. _fixtures: https://docs.pytest.org/en/latest/fixture.html
|
||||
|
||||
@@ -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:
|
||||
|
||||
.. 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
|
||||
as its first argument and can be used like this::
|
||||
|
||||
@@ -110,56 +110,6 @@ process::
|
||||
|
||||
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`:
|
||||
|
||||
Code checks and unit tests
|
||||
|
||||
@@ -1,108 +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'
|
||||
])
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
Generated
-4799
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
+28
-29
@@ -3,7 +3,7 @@ name = "pretix"
|
||||
dynamic = ["version"]
|
||||
description = "Reinventing presales, one ticket at a time"
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.10"
|
||||
license = {file = "LICENSE"}
|
||||
keywords = ["tickets", "web", "shop", "ecommerce"]
|
||||
authors = [
|
||||
@@ -19,31 +19,30 @@ classifiers = [
|
||||
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
||||
"Environment :: Web Environment",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Framework :: Django :: 5.2",
|
||||
"Framework :: Django :: 4.2",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"arabic-reshaper==3.0.1", # Support for Arabic in reportlab
|
||||
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
|
||||
"babel",
|
||||
"BeautifulSoup4==4.15.*",
|
||||
"bleach==6.4.*",
|
||||
"BeautifulSoup4==4.14.*",
|
||||
"bleach==6.3.*",
|
||||
"celery==5.6.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=49.0.0",
|
||||
"css-inline==0.21.*",
|
||||
"defusedcsv>=3.0.0",
|
||||
"cryptography>=44.0.0",
|
||||
"css-inline==0.20.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"dnspython==2.*",
|
||||
"Django[argon2]==5.2.*",
|
||||
"Django[argon2]==4.2.*,>=4.2.26",
|
||||
"django-bootstrap3==26.1",
|
||||
"django-compressor==4.6.0",
|
||||
"django-countries==8.2.*",
|
||||
"django-filter==25.1",
|
||||
"django-formset-js-improved==0.5.0.5",
|
||||
"django-formtools==2.6.1",
|
||||
"django-formtools==2.5.1",
|
||||
"django-hierarkey==2.0.*,>=2.0.1",
|
||||
"django-hijack==3.7.*",
|
||||
"django-i18nfield==1.11.*",
|
||||
@@ -56,11 +55,11 @@ dependencies = [
|
||||
"django-redis==6.0.*",
|
||||
"django-scopes==2.0.*",
|
||||
"django-statici18n==2.7.*",
|
||||
"djangorestframework==3.17.*",
|
||||
"djangorestframework==3.16.*",
|
||||
"dnspython==2.8.*",
|
||||
"drf_ujson2==1.7.*",
|
||||
"geoip2==5.*",
|
||||
"importlib_metadata==9.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||
"isoweek",
|
||||
"jsonschema",
|
||||
"kombu==5.6.*",
|
||||
@@ -74,11 +73,11 @@ dependencies = [
|
||||
"packaging",
|
||||
"paypalrestsdk==1.13.*",
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.13.*",
|
||||
"PyJWT==2.12.*",
|
||||
"phonenumberslite==9.0.*",
|
||||
"Pillow==12.2.*",
|
||||
"Pillow==12.1.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==7.35.*",
|
||||
"protobuf==7.34.*",
|
||||
"psycopg2-binary",
|
||||
"pycountry",
|
||||
"pycparser==3.0",
|
||||
@@ -90,43 +89,41 @@ dependencies = [
|
||||
"pytz-deprecation-shim==0.1.*",
|
||||
"pyuca",
|
||||
"qrcode==8.2",
|
||||
"redis==7.4.*",
|
||||
"reportlab==4.5.*",
|
||||
"redis==7.1.*",
|
||||
"reportlab==4.4.*",
|
||||
"requests==2.32.*",
|
||||
"sentry-sdk==2.63.*",
|
||||
"sentry-sdk==2.54.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
"tlds>=2026041800",
|
||||
"tlds>=2020041600",
|
||||
"tqdm==4.*",
|
||||
"ua-parser==1.0.*",
|
||||
"vobject==0.9.*",
|
||||
"webauthn==2.8.*",
|
||||
"webauthn==2.7.*",
|
||||
"zeep==4.3.*"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
memcached = ["pylibmc"]
|
||||
dev = [
|
||||
"aiohttp==3.14.*",
|
||||
"aiohttp==3.13.*",
|
||||
"coverage",
|
||||
"coveralls",
|
||||
"fakeredis==2.36.*",
|
||||
"fakeredis==2.34.*",
|
||||
"flake8==7.3.*",
|
||||
"freezegun",
|
||||
"isort==8.0.*",
|
||||
"pep8-naming==0.15.*",
|
||||
"potypo",
|
||||
"pytest-asyncio>=1.4.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-cache",
|
||||
"pytest-cov",
|
||||
"pytest-django==4.*",
|
||||
"pytest-mock==3.15.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.8.*",
|
||||
"pytest-playwright",
|
||||
"pytest==9.1.*",
|
||||
"playwright",
|
||||
"pytest==9.0.*",
|
||||
"responses",
|
||||
]
|
||||
|
||||
@@ -139,6 +136,8 @@ build-backend = "backend"
|
||||
backend-path = ["_build"]
|
||||
requires = [
|
||||
"setuptools",
|
||||
"setuptools-rust",
|
||||
"wheel",
|
||||
"importlib_metadata",
|
||||
"tomli",
|
||||
]
|
||||
|
||||
@@ -37,9 +37,4 @@ ignore =
|
||||
CONTRIBUTING.md
|
||||
Dockerfile
|
||||
SECURITY.md
|
||||
eslint.config.mjs
|
||||
package-lock.json
|
||||
package.json
|
||||
tsconfig.json
|
||||
vite.config.js
|
||||
|
||||
|
||||
+6
-6
@@ -9,10 +9,10 @@ localegen:
|
||||
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(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
|
||||
|
||||
compress:
|
||||
compress: npminstall
|
||||
./manage.py compress
|
||||
|
||||
jsi18n: localecompile
|
||||
@@ -25,8 +25,8 @@ coverage:
|
||||
coverage run -m py.test
|
||||
|
||||
npminstall:
|
||||
npm ci
|
||||
|
||||
npmbuild:
|
||||
npm run build
|
||||
# keep this in sync with pretix/_build.py!
|
||||
mkdir -p pretix/static.dist/node_prefix/
|
||||
cp -r pretix/static/npm_dir/* pretix/static.dist/node_prefix/
|
||||
npm ci --prefix=pretix/static.dist/node_prefix
|
||||
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "2026.6.0.dev0"
|
||||
__version__ = "2026.3.0.dev0"
|
||||
|
||||
@@ -37,11 +37,9 @@ INSTALLED_APPS = [
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'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.humanize',
|
||||
'pretix.base',
|
||||
'pretix.control',
|
||||
'pretix.presale',
|
||||
'pretix.multidomain',
|
||||
@@ -245,6 +243,7 @@ STORAGES = {
|
||||
|
||||
COMPRESS_PRECOMPILERS = (
|
||||
('text/x-scss', 'django_libsass.SassCompiler'),
|
||||
('text/vue', 'pretix.helpers.compressor.VueCompiler'),
|
||||
)
|
||||
|
||||
COMPRESS_OFFLINE_CONTEXT = {
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
#
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from setuptools.command.build import build
|
||||
from setuptools.command.build_ext import build_ext
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
project_root = os.path.abspath(os.path.join(here, '..', '..'))
|
||||
npm_installed = False
|
||||
|
||||
|
||||
@@ -35,14 +35,14 @@ def npm_install():
|
||||
global 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
|
||||
|
||||
|
||||
def npm_build():
|
||||
subprocess.check_call('npm run build', shell=True, cwd=project_root)
|
||||
|
||||
|
||||
class CustomBuild(build):
|
||||
def run(self):
|
||||
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
|
||||
@@ -62,7 +62,6 @@ class CustomBuild(build):
|
||||
settings.COMPRESS_OFFLINE = True
|
||||
|
||||
npm_install()
|
||||
npm_build()
|
||||
management.call_command('compilemessages', verbosity=1)
|
||||
management.call_command('compilejsi18n', verbosity=1)
|
||||
management.call_command('collectstatic', verbosity=1, interactive=False)
|
||||
|
||||
@@ -47,5 +47,3 @@ HAS_MEMCACHED = False
|
||||
HAS_CELERY = False
|
||||
HAS_GEOIP = False
|
||||
SENTRY_ENABLED = False
|
||||
VITE_DEV_MODE = False
|
||||
VITE_IGNORE = False
|
||||
|
||||
@@ -110,8 +110,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
('GET', 'api-v1:checkinrpc.search'),
|
||||
('GET', 'api-v1:reusablemedium-list'),
|
||||
('POST', 'api-v1:reusablemedium-lookup'),
|
||||
('PATCH', 'api-v1:reusablemedium-detail')
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -88,19 +88,11 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
|
||||
nonce = serializers.CharField(required=False, allow_null=True)
|
||||
datetime = serializers.DateTimeField(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)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event')
|
||||
|
||||
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):
|
||||
event = serializers.SlugRelatedField(slug_field='slug', read_only=True)
|
||||
|
||||
@@ -73,7 +73,7 @@ from pretix.base.settings import (
|
||||
LazyI18nStringList, validate_event_settings,
|
||||
)
|
||||
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__)
|
||||
|
||||
@@ -173,7 +173,7 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
)
|
||||
|
||||
def get_event_url(self, event):
|
||||
return eventreverse_absolute(event, 'presale:event.index')
|
||||
return build_absolute_uri(event, 'presale:event.index')
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
@@ -871,7 +871,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'og_image',
|
||||
'name_scheme',
|
||||
'reusable_media_active',
|
||||
'reusable_media_usage_enforced',
|
||||
'reusable_media_type_barcode',
|
||||
'reusable_media_type_barcode_identifier_length',
|
||||
'reusable_media_type_nfc_uid',
|
||||
@@ -886,7 +885,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
readonly_fields = [
|
||||
# These are read-only since they are currently only settable on organizers, not events
|
||||
'reusable_media_active',
|
||||
'reusable_media_usage_enforced',
|
||||
'reusable_media_type_barcode',
|
||||
'reusable_media_type_barcode_identifier_length',
|
||||
'reusable_media_type_nfc_uid',
|
||||
@@ -972,7 +970,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'reusable_media_type_nfc_uid',
|
||||
'reusable_media_type_nfc_mf0aes',
|
||||
'reusable_media_type_nfc_mf0aes_random_uid',
|
||||
'reusable_media_usage_enforced',
|
||||
'system_question_order',
|
||||
'tax_rule_payment',
|
||||
'tax_rule_cancellation',
|
||||
|
||||
@@ -133,43 +133,37 @@ class JobRunSerializer(serializers.Serializer):
|
||||
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):
|
||||
schedule_next_run = serializers.DateTimeField(read_only=True)
|
||||
export_identifier = serializers.ChoiceField(choices=[])
|
||||
locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en')
|
||||
owner = serializers.SlugRelatedField(slug_field='email', read_only=True)
|
||||
error_counter = serializers.IntegerField(read_only=True)
|
||||
export_form_data = ExportFormDataField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
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):
|
||||
d = value.replace(' ', '')
|
||||
if len(d.split(',')) > 25:
|
||||
|
||||
@@ -115,10 +115,10 @@ class PluginsField(serializers.Field):
|
||||
|
||||
def to_representation(self, obj):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
active_plugins = set(obj.get_plugins())
|
||||
|
||||
return sorted([
|
||||
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):
|
||||
|
||||
@@ -45,12 +45,6 @@ class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
|
||||
return 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):
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -191,7 +191,7 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
|
||||
class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemProgramTime
|
||||
fields = ('start', 'end', 'location')
|
||||
fields = ('start', 'end')
|
||||
|
||||
|
||||
class ItemBundleSerializer(serializers.ModelSerializer):
|
||||
@@ -222,7 +222,7 @@ class ItemBundleSerializer(serializers.ModelSerializer):
|
||||
class ItemProgramTimeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemProgramTime
|
||||
fields = ('id', 'start', 'end', 'location')
|
||||
fields = ('id', 'start', 'end')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
@@ -31,9 +31,7 @@ from pretix.api.serializers.order import OrderPositionSerializer
|
||||
from pretix.api.serializers.organizer import (
|
||||
CustomerSerializer, GiftCardSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Device, Order, OrderPosition, ReusableMedium, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models import Order, OrderPosition, ReusableMedium
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,14 +64,13 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
|
||||
def __init__(self, *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"]:
|
||||
raise PermissionDenied("No permission to access gift card details.")
|
||||
|
||||
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)
|
||||
else:
|
||||
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
|
||||
@@ -82,27 +79,18 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
queryset=self.context['organizer'].issued_gift_cards.all()
|
||||
)
|
||||
|
||||
# keep linked_orderposition (singular) for backwards compatibility, will be overwritten in self.validate
|
||||
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
|
||||
required=False,
|
||||
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
|
||||
)
|
||||
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
|
||||
# No additional permission check performed, documented limitation of the permission system
|
||||
# Would get to complex/unusable otherwise since the permission depends on the event
|
||||
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
|
||||
else:
|
||||
self.fields['linked_orderpositions'] = serializers.PrimaryKeyRelatedField(
|
||||
many=True,
|
||||
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
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"]:
|
||||
raise PermissionDenied("No permission to access customer details.")
|
||||
|
||||
@@ -117,21 +105,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
|
||||
def validate(self, 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:
|
||||
qs = self.context['organizer'].reusable_media.filter(
|
||||
identifier=data['identifier'], type=data['type']
|
||||
@@ -144,41 +117,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
return data
|
||||
|
||||
def to_representation(self, instance):
|
||||
r = super().to_representation(instance)
|
||||
request = self.context.get('request')
|
||||
|
||||
ops = r.get('linked_orderpositions', [])
|
||||
# late permission evaluations for checks that depend on the actual linked events
|
||||
expand_nested = self.context['request'].query_params.getlist('expand')
|
||||
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:
|
||||
ops_noperm = []
|
||||
for lop in instance.linked_orderpositions.all():
|
||||
event = lop.order.event
|
||||
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
|
||||
ops_noperm.append(lop.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:
|
||||
gc = instance.linked_giftcard
|
||||
if gc is not None and gc.owner_ticket is not None:
|
||||
event = gc.owner_ticket.order.event
|
||||
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
|
||||
r['linked_giftcard']['owner_ticket'] = {'id': instance.linked_giftcard.owner_ticket.id}
|
||||
|
||||
return r
|
||||
|
||||
class Meta:
|
||||
model = ReusableMedium
|
||||
fields = (
|
||||
@@ -188,12 +126,10 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
'updated',
|
||||
'type',
|
||||
'identifier',
|
||||
'claim_token',
|
||||
'label',
|
||||
'active',
|
||||
'expires',
|
||||
'customer',
|
||||
'linked_orderpositions',
|
||||
'linked_orderposition',
|
||||
'linked_giftcard',
|
||||
'info',
|
||||
'notes',
|
||||
|
||||
@@ -76,7 +76,7 @@ from pretix.base.settings import (
|
||||
)
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
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__)
|
||||
|
||||
@@ -757,7 +757,7 @@ class PaymentURLField(serializers.URLField):
|
||||
def to_representation(self, instance: OrderPayment):
|
||||
if instance.state != OrderPayment.PAYMENT_STATE_CREATED:
|
||||
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,
|
||||
'secret': instance.order.secret,
|
||||
'payment': instance.pk,
|
||||
@@ -769,11 +769,7 @@ class PaymentDetailsField(serializers.Field):
|
||||
pp = value.payment_provider
|
||||
if not pp:
|
||||
return {}
|
||||
try:
|
||||
return pp.api_payment_details(value)
|
||||
except Exception:
|
||||
logger.exception("Failed to retrieve payment_details")
|
||||
return {}
|
||||
return pp.api_payment_details(value)
|
||||
|
||||
|
||||
class OrderPaymentSerializer(I18nAwareModelSerializer):
|
||||
@@ -806,7 +802,7 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class OrderURLField(serializers.URLField):
|
||||
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,
|
||||
'secret': instance.secret,
|
||||
})
|
||||
@@ -1149,7 +1145,6 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
{'discount': ['You can only specify a discount if you do the price computation, but price is not set.']}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -1417,7 +1412,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
qa = QuotaAvailability()
|
||||
qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0])
|
||||
qa.compute()
|
||||
v_avail = {}
|
||||
|
||||
# These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental
|
||||
# use further down
|
||||
@@ -1447,13 +1441,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
voucher_usage[v] += 1
|
||||
if voucher_usage[v] > 0:
|
||||
if v not in v_avail:
|
||||
v.refresh_from_db(fields=['redeemed'])
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=v) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
|
||||
).exclude(pk__in=[cp.pk for cp in delete_cps])
|
||||
v_avail[v] = v.max_usages - v.redeemed - redeemed_in_carts.count()
|
||||
if v_avail[v] < voucher_usage[v]:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
|
||||
).exclude(pk__in=[cp.pk for cp in delete_cps])
|
||||
v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
|
||||
if v_avail < voucher_usage[v]:
|
||||
errs[i]['voucher'] = [
|
||||
'The voucher has already been used the maximum number of times.'
|
||||
]
|
||||
@@ -1589,7 +1581,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
pos_data['attendee_name_parts'] = {
|
||||
'_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:
|
||||
pos.order = order._wrapped
|
||||
else:
|
||||
@@ -1704,25 +1696,15 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
answ.options.add(*options)
|
||||
|
||||
if use_reusable_medium:
|
||||
if pos.item.media_policy not in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW):
|
||||
for op_pk in use_reusable_medium.linked_orderpositions.values_list('pk', flat=True):
|
||||
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.linked_orderposition = pos
|
||||
use_reusable_medium.save(update_fields=['linked_orderposition'])
|
||||
use_reusable_medium.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.added',
|
||||
'pretix.reusable_medium.linked_orderposition.changed',
|
||||
data={
|
||||
'by_order': order.code,
|
||||
'linked_orderposition': pos.pk,
|
||||
}
|
||||
)
|
||||
use_reusable_medium.touch()
|
||||
|
||||
if not simulate:
|
||||
for cp in delete_cps:
|
||||
|
||||
@@ -58,8 +58,8 @@ from pretix.helpers.permission_migration import (
|
||||
OLD_TO_NEW_EVENT_COMPAT, OLD_TO_NEW_EVENT_MIGRATION,
|
||||
OLD_TO_NEW_ORGANIZER_COMPAT, OLD_TO_NEW_ORGANIZER_MIGRATION,
|
||||
)
|
||||
from pretix.helpers.urls import mainreverse_absolute
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,7 +71,7 @@ class OrganizerSerializer(I18nAwareModelSerializer):
|
||||
slug = serializers.CharField(read_only=True)
|
||||
|
||||
def get_organizer_url(self, organizer):
|
||||
return eventreverse_absolute(organizer, 'presale:organizer.index')
|
||||
return build_absolute_uri(organizer, 'presale:organizer.index')
|
||||
|
||||
class Meta:
|
||||
model = Organizer
|
||||
@@ -286,19 +286,6 @@ class GiftCardSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
return data
|
||||
|
||||
def to_representation(self, instance):
|
||||
r = super().to_representation(instance)
|
||||
request = self.context.get('request')
|
||||
# late permission evaluations for checks that depend on the actual linked events
|
||||
if 'owner_ticket' in self.context['request'].query_params.getlist('expand'):
|
||||
owner_ticket = instance.owner_ticket
|
||||
if owner_ticket:
|
||||
event = owner_ticket.order.event
|
||||
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
|
||||
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
|
||||
r['owner_ticket'] = {'id': instance.owner_ticket.id}
|
||||
return r
|
||||
|
||||
class Meta:
|
||||
model = GiftCard
|
||||
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket',
|
||||
@@ -499,7 +486,7 @@ class TeamInviteSerializer(serializers.ModelSerializer):
|
||||
'user': self,
|
||||
'organizer': self.context['organizer'].name,
|
||||
'team': instance.team.name,
|
||||
'url': mainreverse_absolute('control:auth.invite', kwargs={
|
||||
'url': build_global_uri('control:auth.invite', kwargs={
|
||||
'token': instance.token
|
||||
})
|
||||
},
|
||||
@@ -605,7 +592,6 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
'cookie_consent_dialog_button_yes',
|
||||
'cookie_consent_dialog_button_no',
|
||||
'reusable_media_active',
|
||||
'reusable_media_usage_enforced',
|
||||
'reusable_media_type_barcode',
|
||||
'reusable_media_type_barcode_identifier_length',
|
||||
'reusable_media_type_nfc_uid',
|
||||
|
||||
+26
-139
@@ -69,10 +69,8 @@ from pretix.base.models import (
|
||||
from pretix.base.models.orders import PrintLog
|
||||
from pretix.base.permissions import AnyPermissionOf
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredMediaExchangeError, RequiredQuestionsError, SQLLogic,
|
||||
perform_checkin,
|
||||
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
|
||||
)
|
||||
from pretix.base.services.media import perform_media_exchange
|
||||
from pretix.base.signals import checkin_annulled
|
||||
from pretix.helpers import OF_SELF
|
||||
|
||||
@@ -456,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,
|
||||
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,
|
||||
exchange_medium_type=None, exchange_medium_identifier=None):
|
||||
source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False):
|
||||
if not checkinlists:
|
||||
raise ValidationError('No check-in list passed.')
|
||||
|
||||
@@ -466,7 +463,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
|
||||
device = auth if isinstance(auth, Device) else None
|
||||
gate = gate or (auth.gate if isinstance(auth, Device) else None)
|
||||
medium = None
|
||||
|
||||
context = {
|
||||
'request': request,
|
||||
@@ -495,7 +491,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
)
|
||||
raw_barcode_for_checkin = None
|
||||
from_revoked_secret = False
|
||||
reusable_medium_used = None
|
||||
if simulate:
|
||||
common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True
|
||||
|
||||
@@ -526,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)
|
||||
if not op_candidates:
|
||||
try:
|
||||
medium = ReusableMedium.objects.active().filter(
|
||||
Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk')))
|
||||
).get(
|
||||
media = ReusableMedium.objects.select_related('linked_orderposition').active().get(
|
||||
organizer_id=checkinlists[0].event.organizer_id,
|
||||
type=source_type,
|
||||
identifier=raw_barcode,
|
||||
linked_orderposition__isnull=False,
|
||||
)
|
||||
raw_barcode_for_checkin = raw_barcode
|
||||
except ReusableMedium.DoesNotExist:
|
||||
@@ -634,9 +628,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
|
||||
}, status=400)
|
||||
else:
|
||||
linked_ops = medium.linked_orderpositions.all().select_related("order").prefetch_related("addons")
|
||||
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):
|
||||
if media.linked_orderposition.order.event_id not in list_by_event:
|
||||
# Medium exists but connected ticket is for the wrong event
|
||||
if not simulate:
|
||||
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
|
||||
@@ -662,91 +654,28 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'checkin_texts': [],
|
||||
'list': MiniCheckinListSerializer(checkinlists[0]).data,
|
||||
}, status=404)
|
||||
op_candidates = []
|
||||
for op in linked_ops:
|
||||
if op.order.event_id in list_by_event:
|
||||
reusable_medium_used = medium
|
||||
op_candidates.append(op)
|
||||
if list_by_event[op.order.event_id].addon_match:
|
||||
op_candidates += list(op.addons.all())
|
||||
op_candidates = [media.linked_orderposition]
|
||||
if list_by_event[media.linked_orderposition.order.event_id].addon_match:
|
||||
op_candidates += list(media.linked_orderposition.addons.all())
|
||||
|
||||
# 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
|
||||
# here and need to figure out which op has the right product. This basically is a valid-for-checkin-test on every op.
|
||||
# key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
|
||||
# which add-on has the right product.
|
||||
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:
|
||||
# 3a. First, we clean up that we made an imprecise query above. If a scan is made for multiple check-in lists,
|
||||
# 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
|
||||
if len(op_candidates_matching_product) == 0:
|
||||
# None of the found add-ons 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.
|
||||
# To improve the error message, we select the op that will "work next" or - if none matches - "worked last".
|
||||
op_candidate = None
|
||||
for op in op_candidates:
|
||||
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:
|
||||
# This has the advantage of a better error message.
|
||||
op_candidates = [op_candidates[0]]
|
||||
elif len(op_candidates_matching_product) > 1:
|
||||
# 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
|
||||
# base product according to our order_by above.
|
||||
@@ -780,7 +709,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||
}, status=400)
|
||||
else:
|
||||
op_candidates = op_candidates_filtered
|
||||
op_candidates = op_candidates_matching_product
|
||||
|
||||
op = op_candidates[0]
|
||||
common_checkin_args['list'] = list_by_event[op.order.event_id]
|
||||
@@ -792,10 +721,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
if str(q.pk) in answers_data:
|
||||
try:
|
||||
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)
|
||||
else:
|
||||
given_answers[q] = None
|
||||
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
|
||||
else:
|
||||
given_answers[q] = q.clean_answer(answers_data[str(q.pk)])
|
||||
except (ValidationError, BaseValidationError):
|
||||
@@ -808,14 +734,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
locale = op.order.event.settings.locale
|
||||
with language(locale):
|
||||
try:
|
||||
if exchange_medium_identifier and medium:
|
||||
# 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(
|
||||
perform_checkin(
|
||||
op=op,
|
||||
clist=list_by_event[op.order.event_id],
|
||||
given_answers=given_answers,
|
||||
@@ -833,25 +752,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
from_revoked_secret=from_revoked_secret,
|
||||
simulate=simulate,
|
||||
gate=gate,
|
||||
reusable_medium=medium,
|
||||
)
|
||||
|
||||
if exchange_medium_identifier: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate
|
||||
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:
|
||||
return Response({
|
||||
'status': 'incomplete',
|
||||
@@ -863,18 +764,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
],
|
||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||
}, 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:
|
||||
if not simulate:
|
||||
op.order.log_action('pretix.event.checkin.denied', data={
|
||||
@@ -1062,8 +951,6 @@ class CheckinRPCRedeemView(views.APIView):
|
||||
canceled_supported=True,
|
||||
request=self.request, # this is not clean, but we need it in the serializers for URL generation
|
||||
legacy_url_support=False,
|
||||
exchange_medium_type=s.validated_data.get('exchange_medium_type'),
|
||||
exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'),
|
||||
)
|
||||
|
||||
|
||||
@@ -1235,7 +1122,7 @@ class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission = 'event.orders:read'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Checkin.all.filter(list__event=self.request.event).select_related(
|
||||
qs = Checkin.all.filter().select_related(
|
||||
"position",
|
||||
"device",
|
||||
)
|
||||
|
||||
@@ -53,12 +53,10 @@ with scopes_disabled():
|
||||
customer = django_filters.CharFilter(field_name='customer__identifier')
|
||||
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', 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:
|
||||
model = ReusableMedium
|
||||
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderpositions', 'linked_giftcard']
|
||||
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard']
|
||||
|
||||
|
||||
class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
@@ -77,7 +75,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
||||
return self.request.organizer.reusable_media.prefetch_related(
|
||||
Prefetch(
|
||||
'linked_orderpositions',
|
||||
'linked_orderposition',
|
||||
queryset=OrderPosition.objects.select_related(
|
||||
'order', 'order__event', 'order__event__organizer', 'seat',
|
||||
).prefetch_related(
|
||||
@@ -119,38 +117,14 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
rm = 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))
|
||||
ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
|
||||
inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type)
|
||||
linked_ops_pks = inst.linked_orderpositions.values_list("pk", flat=True)
|
||||
for op_pk in prev_linked_ops_pks:
|
||||
if op_pk not in linked_ops_pks:
|
||||
inst.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.removed',
|
||||
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,
|
||||
)
|
||||
inst.log_action(
|
||||
'pretix.reusable_medium.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
)
|
||||
return inst
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
@@ -183,6 +157,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
type=s.validated_data["type"],
|
||||
identifier=s.validated_data["identifier"],
|
||||
)
|
||||
m.linked_orderposition = None # not relevant for cross-organizer
|
||||
m.customer = None # not relevant for cross-organizer
|
||||
s = self.get_serializer(m)
|
||||
return Response({"result": s.data})
|
||||
@@ -196,7 +171,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
|
||||
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):
|
||||
date = serializers.DateTimeField().to_representation(now())
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
@@ -194,7 +194,7 @@ with scopes_disabled():
|
||||
)
|
||||
).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 = (
|
||||
code
|
||||
@@ -381,15 +381,12 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
|
||||
return resp
|
||||
else:
|
||||
return FileResponse(
|
||||
ct.file.file,
|
||||
filename='{}-{}-{}{}'.format(
|
||||
self.request.event.slug.upper(), order.code,
|
||||
provider.identifier, ct.extension
|
||||
),
|
||||
as_attachment=True,
|
||||
content_type=ct.type
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), order.code,
|
||||
provider.identifier, ct.extension
|
||||
)
|
||||
return resp
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def mark_paid(self, request, **kwargs):
|
||||
@@ -1034,7 +1031,7 @@ with scopes_disabled():
|
||||
search = django_filters.CharFilter(method='search_qs')
|
||||
|
||||
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(
|
||||
Q(secret__istartswith=value)
|
||||
| Q(attendee_name_cached__icontains=value)
|
||||
@@ -1306,17 +1303,14 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
|
||||
raise NotFound()
|
||||
|
||||
ftype, ignored = mimetypes.guess_type(answer.file.name)
|
||||
return FileResponse(
|
||||
answer.file,
|
||||
filename='{}-{}-{}-{}'.format(
|
||||
self.request.event.slug.upper(),
|
||||
pos.order.code,
|
||||
pos.positionid,
|
||||
os.path.basename(answer.file.name).split('.', 1)[1]
|
||||
),
|
||||
as_attachment=True,
|
||||
content_type=ftype or 'application/binary'
|
||||
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format(
|
||||
self.request.event.slug.upper(),
|
||||
pos.order.code,
|
||||
pos.positionid,
|
||||
os.path.basename(answer.file.name).split('.', 1)[1]
|
||||
)
|
||||
return resp
|
||||
|
||||
@action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"])
|
||||
def printlog(self, request, **kwargs):
|
||||
@@ -1371,18 +1365,15 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
|
||||
if hasattr(image_file, 'seek'):
|
||||
image_file.seek(0)
|
||||
|
||||
return FileResponse(
|
||||
image_file,
|
||||
filename='{}-{}-{}-{}.{}'.format(
|
||||
self.request.event.slug.upper(),
|
||||
pos.order.code,
|
||||
pos.positionid,
|
||||
key,
|
||||
extension,
|
||||
),
|
||||
as_attachment=True,
|
||||
content_type=ftype or 'application/binary'
|
||||
resp = FileResponse(image_file, content_type=ftype or 'application/binary')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format(
|
||||
self.request.event.slug.upper(),
|
||||
pos.order.code,
|
||||
pos.positionid,
|
||||
key,
|
||||
extension,
|
||||
)
|
||||
return resp
|
||||
|
||||
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
|
||||
def download(self, request, output, **kwargs):
|
||||
@@ -1408,15 +1399,12 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
|
||||
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
|
||||
return resp
|
||||
else:
|
||||
return FileResponse(
|
||||
ct.file.file,
|
||||
filename='{}-{}-{}-{}{}'.format(
|
||||
self.request.event.slug.upper(), pos.order.code, pos.positionid,
|
||||
provider.identifier, ct.extension
|
||||
),
|
||||
as_attachment=True,
|
||||
content_type=ct.type
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), pos.order.code, pos.positionid,
|
||||
provider.identifier, ct.extension
|
||||
)
|
||||
return resp
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def regenerate_secrets(self, request, **kwargs):
|
||||
@@ -1998,12 +1986,9 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
if not invoice.file:
|
||||
raise RetryException()
|
||||
|
||||
return FileResponse(
|
||||
invoice.file.file,
|
||||
filename='{}.pdf'.format(invoice.number),
|
||||
as_attachment=True,
|
||||
content_type='application/pdf'
|
||||
)
|
||||
resp = FileResponse(invoice.file.file, content_type='application/pdf')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
|
||||
return resp
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def transmit(self, request, **kwargs):
|
||||
|
||||
@@ -408,12 +408,6 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
_('This includes product added or deleted and changes to nested objects like '
|
||||
'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(
|
||||
'pretix.event.live.activated',
|
||||
_('Shop taken live'),
|
||||
|
||||
@@ -36,7 +36,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from requests import RequestException
|
||||
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
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):
|
||||
payload = {
|
||||
'iss': eventreverse_absolute(client.organizer, 'presale:organizer.index').rstrip('/'),
|
||||
'iss': build_absolute_uri(client.organizer, 'presale:organizer.index').rstrip('/'),
|
||||
'aud': client.client_id,
|
||||
'exp': int(expires.timestamp()),
|
||||
'iat': int(time.time()),
|
||||
|
||||
@@ -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.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):
|
||||
@@ -545,7 +545,7 @@ def get_data_fields(event, for_model=None):
|
||||
_("Order link"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: eventreverse_absolute(
|
||||
lambda order: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
@@ -560,7 +560,7 @@ def get_data_fields(event, for_model=None):
|
||||
_("Ticket link"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda op: eventreverse_absolute(
|
||||
lambda op: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.position', kwargs={
|
||||
'order': op.order.code,
|
||||
|
||||
@@ -19,10 +19,7 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import ipaddress
|
||||
import logging
|
||||
import smtplib
|
||||
import socket
|
||||
from itertools import groupby
|
||||
from smtplib import SMTPResponseException
|
||||
from typing import TypeVar
|
||||
@@ -240,80 +237,3 @@ def base_renderers(sender, **kwargs):
|
||||
|
||||
def get_email_context(**kwargs):
|
||||
return PlaceholderContext(**kwargs).render_all()
|
||||
|
||||
|
||||
def create_connection(address, timeout=socket.getdefaulttimeout(),
|
||||
source_address=None, *, all_errors=False):
|
||||
# Taken from the python stdlib, extended with a check for local ips
|
||||
|
||||
host, port = address
|
||||
exceptions = []
|
||||
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
|
||||
af, socktype, proto, canonname, sa = res
|
||||
|
||||
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
|
||||
ip_addr = ipaddress.ip_address(sa[0])
|
||||
if ip_addr.is_multicast:
|
||||
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
|
||||
try:
|
||||
sock = socket.socket(af, socktype, proto)
|
||||
if timeout is not socket.getdefaulttimeout():
|
||||
sock.settimeout(timeout)
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sa)
|
||||
# Break explicitly a reference cycle
|
||||
exceptions.clear()
|
||||
return sock
|
||||
|
||||
except socket.error as exc:
|
||||
if not all_errors:
|
||||
exceptions.clear() # raise only the last error
|
||||
exceptions.append(exc)
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
|
||||
if len(exceptions):
|
||||
try:
|
||||
if not all_errors:
|
||||
raise exceptions[0]
|
||||
raise ExceptionGroup("create_connection failed", exceptions)
|
||||
finally:
|
||||
# Break explicitly a reference cycle
|
||||
exceptions.clear()
|
||||
else:
|
||||
raise socket.error("getaddrinfo returns an empty list")
|
||||
|
||||
|
||||
class CheckPrivateNetworkMixin:
|
||||
# _get_socket taken 1:1 from smtplib, just with a call to our own create_connection
|
||||
def _get_socket(self, host, port, timeout):
|
||||
# This makes it simpler for SMTP_SSL to use the SMTP connect code
|
||||
# and just alter the socket connection bit.
|
||||
if timeout is not None and not timeout:
|
||||
raise ValueError('Non-blocking socket (timeout=0) is not supported')
|
||||
if self.debuglevel > 0:
|
||||
self._print_debug('connect: to', (host, port), self.source_address)
|
||||
return create_connection((host, port), timeout, self.source_address)
|
||||
|
||||
|
||||
class SMTP(CheckPrivateNetworkMixin, smtplib.SMTP):
|
||||
pass
|
||||
|
||||
|
||||
# SMTP used here instead of mixin, because smtp.SMTP_SSL._get_socket calls super()._get_socket and then wraps this socket
|
||||
# super()._get_socket needs to be our version from the mixin
|
||||
class SMTP_SSL(smtplib.SMTP_SSL, SMTP): # noqa: N801
|
||||
pass
|
||||
|
||||
|
||||
class CheckPrivateNetworkSmtpBackend(EmailBackend):
|
||||
@property
|
||||
def connection_class(self):
|
||||
return SMTP_SSL if self.use_ssl else SMTP
|
||||
|
||||
@@ -47,7 +47,6 @@ from django.utils.formats import localize
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.models.auth import PermissionHolder
|
||||
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
|
||||
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
|
||||
)
|
||||
@@ -60,20 +59,11 @@ class BaseExporter:
|
||||
This is the base class for all data exporters
|
||||
"""
|
||||
|
||||
def __init__(self, event, organizer, permission_holder: PermissionHolder=None, progress_callback=lambda v: None):
|
||||
"""
|
||||
:param event: Event context, can also be a queryset of events for multi-event exports
|
||||
:param organizer: Organizer context
|
||||
:param user: The user who triggered the export (or None).
|
||||
:param token: The API token that triggered the export (or None).
|
||||
:param device: The device that triggered the export (or None)
|
||||
:param progress_callback: Callback function with progress
|
||||
"""
|
||||
def __init__(self, event, organizer, progress_callback=lambda v: None):
|
||||
self.event = event
|
||||
self.organizer = organizer
|
||||
self.progress_callback = progress_callback
|
||||
self.is_multievent = isinstance(event, QuerySet)
|
||||
self.permission_holder = permission_holder
|
||||
if isinstance(event, QuerySet):
|
||||
self.events = event
|
||||
self.event = None
|
||||
@@ -190,7 +180,7 @@ class BaseExporter:
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_required_event_permission(cls) -> Optional[str]:
|
||||
def get_required_event_permission(cls) -> str:
|
||||
"""
|
||||
The permission level required to use this exporter for events. For multi-event-exports, this will be used
|
||||
to limit the selection of events. Will be ignored if the ``OrganizerLevelExportMixin`` mixin is used.
|
||||
@@ -205,7 +195,7 @@ class OrganizerLevelExportMixin:
|
||||
raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin")
|
||||
|
||||
@classmethod
|
||||
def get_required_organizer_permission(cls) -> Optional[str]:
|
||||
def get_required_organizer_permission(cls) -> str:
|
||||
"""
|
||||
The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to
|
||||
allow everyone with any access to the organizer.
|
||||
|
||||
@@ -68,7 +68,7 @@ from ...control.forms.filter import get_all_payment_providers
|
||||
from ...helpers import GroupConcat
|
||||
from ...helpers.iter import chunked_iterable
|
||||
from ...helpers.safe_openpyxl import remove_invalid_excel_chars
|
||||
from ...multidomain.urlreverse import eventreverse_absolute
|
||||
from ...multidomain.urlreverse import build_absolute_uri
|
||||
from ..exporter import (
|
||||
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
|
||||
)
|
||||
@@ -160,7 +160,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
def _get_all_payment_methods(self, qs):
|
||||
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(
|
||||
'provider', flat=True
|
||||
).distinct()
|
||||
@@ -330,7 +330,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
taxsum=Sum('tax_value'), grosssum=Sum('value')
|
||||
)
|
||||
}
|
||||
payment_methods = None
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_sum_cache = {
|
||||
(o['order__id'], o['provider']): o['grosssum'] for o in
|
||||
@@ -348,7 +347,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
grosssum=Sum('amount')
|
||||
)
|
||||
}
|
||||
payment_methods = self._get_all_payment_methods(qs)
|
||||
sum_cache = {
|
||||
(o['order__id'], o['tax_rate']): o for o in
|
||||
OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate(
|
||||
@@ -429,13 +427,14 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
]))
|
||||
|
||||
row.append(
|
||||
eventreverse_absolute(order.event, 'presale:event.order', kwargs={
|
||||
build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
})
|
||||
)
|
||||
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_methods = self._get_all_payment_methods(qs)
|
||||
for id, vn in payment_methods:
|
||||
row.append(
|
||||
payment_sum_cache.get((order.id, id), Decimal('0.00')) -
|
||||
@@ -855,7 +854,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
]))
|
||||
|
||||
row.append(
|
||||
eventreverse_absolute(order.event, 'presale:event.order.position', kwargs={
|
||||
build_absolute_uri(order.event, 'presale:event.order.position', kwargs={
|
||||
'order': order.code,
|
||||
'secret': op.web_secret,
|
||||
'position': op.positionid
|
||||
@@ -1104,25 +1103,13 @@ class PaymentListExporter(ListExporter):
|
||||
def iterate_list(self, form_data):
|
||||
provider_names = dict(get_all_payment_providers())
|
||||
|
||||
i_numbers = Invoice.objects.filter(
|
||||
order=OuterRef('order_id'),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('full_invoice_no', delimiter=', ')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
|
||||
payments = OrderPayment.objects.filter(
|
||||
order__event__in=self.events,
|
||||
state__in=form_data.get('payment_states', [])
|
||||
).annotate(
|
||||
order_invoice_numbers=Subquery(i_numbers, output_field=CharField()),
|
||||
).select_related('order').prefetch_related('order__event').order_by('created')
|
||||
refunds = OrderRefund.objects.filter(
|
||||
order__event__in=self.events,
|
||||
state__in=form_data.get('refund_states', [])
|
||||
).annotate(
|
||||
order_invoice_numbers=Subquery(i_numbers, output_field=CharField()),
|
||||
).select_related('order').prefetch_related('order__event').order_by('created')
|
||||
|
||||
if form_data.get('end_date_range'):
|
||||
@@ -1148,7 +1135,6 @@ class PaymentListExporter(ListExporter):
|
||||
headers = [
|
||||
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||
_('Status code'), _('Amount'), _('Payment method'), _('Comment'), _('Matching ID'), _('Payment details'),
|
||||
_('Invoice numbers'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
@@ -1186,7 +1172,6 @@ class PaymentListExporter(ListExporter):
|
||||
obj.comment if isinstance(obj, OrderRefund) else "",
|
||||
matching_id,
|
||||
payment_details,
|
||||
obj.order_invoice_numbers,
|
||||
]
|
||||
yield row
|
||||
|
||||
|
||||
@@ -20,13 +20,12 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy
|
||||
|
||||
from ..exporter import ListExporter, OrganizerLevelExportMixin
|
||||
from ..models import OrderPosition, ReusableMedium
|
||||
from ..models import ReusableMedium
|
||||
from ..signals import register_multievent_data_exporters
|
||||
|
||||
|
||||
@@ -45,9 +44,7 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
media = ReusableMedium.objects.filter(
|
||||
organizer=self.organizer,
|
||||
).select_related(
|
||||
'customer', 'linked_giftcard',
|
||||
).prefetch_related(
|
||||
Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order"))
|
||||
'customer', 'linked_orderposition', 'linked_giftcard',
|
||||
).order_by('created')
|
||||
|
||||
headers = [
|
||||
@@ -64,23 +61,18 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
yield headers
|
||||
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):
|
||||
giftcard_secret = medium.linked_giftcard.secret if medium.linked_giftcard_id else ''
|
||||
if giftcard_secret and not can_read_giftcards:
|
||||
giftcard_secret = giftcard_secret[:3] + "…"
|
||||
|
||||
yield [
|
||||
row = [
|
||||
medium.type,
|
||||
medium.identifier,
|
||||
_('Yes') if medium.active else _('No'),
|
||||
date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '',
|
||||
medium.customer.identifier if medium.customer_id else '',
|
||||
', '.join([f"{op.order.code}-{op.positionid}" for op in medium.linked_orderpositions.all()]),
|
||||
giftcard_secret,
|
||||
f"{medium.linked_orderposition.order.code}-{medium.linked_orderposition.positionid}" if medium.linked_orderposition_id else '',
|
||||
medium.linked_giftcard.secret if medium.linked_giftcard_id else '',
|
||||
medium.notes,
|
||||
]
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return f'{self.organizer.slug}_media'
|
||||
|
||||
@@ -196,7 +196,8 @@ class RegistrationForm(forms.Form):
|
||||
def clean_password(self):
|
||||
password1 = self.cleaned_data.get('password', '')
|
||||
user = User(email=self.cleaned_data.get('email'))
|
||||
validate_password(password1, user=user)
|
||||
if validate_password(password1, user=user) is not None:
|
||||
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
|
||||
return password1
|
||||
|
||||
def clean_email(self):
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
@@ -46,9 +45,12 @@ import pycountry
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.gis.geoip2 import GeoIP2
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.core.validators import (
|
||||
MaxValueValidator, MinValueValidator, RegexValidator,
|
||||
)
|
||||
from django.db.models import QuerySet
|
||||
from django.forms import Select, widgets
|
||||
from django.forms.widgets import FILE_INPUT_CONTRADICTION
|
||||
@@ -89,7 +91,7 @@ from pretix.base.settings import (
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
|
||||
PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import URL_RE, rich_text
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
|
||||
@@ -100,7 +102,6 @@ from pretix.helpers.countries import (
|
||||
from pretix.helpers.escapejson import escapejson_attr
|
||||
from pretix.helpers.http import get_client_ip
|
||||
from pretix.helpers.i18n import get_format_without_seconds
|
||||
from pretix.helpers.security import get_geoip
|
||||
from pretix.presale.signals import question_form_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -219,8 +220,16 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
defaults = {
|
||||
'widget': self.widget,
|
||||
'max_length': kwargs.pop('max_length', None),
|
||||
'validators': [
|
||||
RegexValidator(
|
||||
# The following characters should never appear in a name anywhere of
|
||||
# the world. However, they commonly appear in inputs generated by spam
|
||||
# bots.
|
||||
r'^[^$€/%§{}<>~]*$',
|
||||
message=_('Please do not use special characters in names.')
|
||||
)
|
||||
]
|
||||
}
|
||||
self.max_length = defaults['max_length']
|
||||
self.scheme_name = kwargs.pop('scheme')
|
||||
self.titles = kwargs.pop('titles')
|
||||
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
|
||||
@@ -240,6 +249,7 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
if fname == 'title' and self.scheme_titles:
|
||||
d = dict(defaults)
|
||||
d.pop('max_length', None)
|
||||
d.pop('validators', None)
|
||||
field = forms.ChoiceField(
|
||||
**d,
|
||||
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
|
||||
@@ -248,6 +258,7 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
elif fname == 'salutation':
|
||||
d = dict(defaults)
|
||||
d.pop('max_length', None)
|
||||
d.pop('validators', None)
|
||||
field = forms.ChoiceField(
|
||||
**d,
|
||||
choices=[
|
||||
@@ -276,40 +287,9 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
if self.require_all_fields and not all(v for v in value):
|
||||
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
|
||||
|
||||
if sum(len(v) for v in value.values() if v) > (self.max_length or 250):
|
||||
if sum(len(v) for v in value.values() if v) > 250:
|
||||
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
|
||||
|
||||
for fname, label, size in self.scheme['fields']:
|
||||
if fname == 'salutation' or (fname == 'title' and self.scheme_titles):
|
||||
continue
|
||||
v = value.get(fname)
|
||||
if not v:
|
||||
continue
|
||||
special_chars = re.findall('[$€/%§{}<>~]', v)
|
||||
if special_chars:
|
||||
raise forms.ValidationError(
|
||||
_('The field "%(label)s" may not contain special characters such as "%(chars)s".'),
|
||||
code='name_special_chars',
|
||||
params={
|
||||
"label": label,
|
||||
"chars": "".join(special_chars),
|
||||
},
|
||||
)
|
||||
# URL_RE checks for valid domain names, including one special TLD med, which can be part of a title
|
||||
if ".med" in v:
|
||||
v = v.replace(".med", ". med")
|
||||
value[fname] = v
|
||||
url_matched = URL_RE.search(v)
|
||||
if url_matched:
|
||||
raise forms.ValidationError(
|
||||
_('The field "%(label)s" may not contain an URL (%(url)s).'),
|
||||
code='url_in_title',
|
||||
params={
|
||||
"label": label,
|
||||
"url": url_matched.group(0),
|
||||
}
|
||||
)
|
||||
|
||||
if value.get("salutation") == "empty":
|
||||
value["salutation"] = ""
|
||||
|
||||
@@ -413,7 +393,7 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
||||
|
||||
def guess_country_from_request(request, event):
|
||||
if settings.HAS_GEOIP:
|
||||
g = get_geoip()
|
||||
g = GeoIP2()
|
||||
try:
|
||||
res = g.country(get_client_ip(request))
|
||||
if res['country_code'] and len(res['country_code']) == 2:
|
||||
|
||||
@@ -1160,7 +1160,7 @@ class Modern1Renderer(ClassicInvoiceRenderer):
|
||||
return stylesheet
|
||||
|
||||
def _draw_invoice_from(self, canvas):
|
||||
if not self.invoice.address_invoice_from:
|
||||
if not self.invoice.invoice_from:
|
||||
return
|
||||
c = [
|
||||
self._clean_text(l)
|
||||
|
||||
@@ -36,9 +36,8 @@ from django.core.management.commands.makemigrations import Command as Parent
|
||||
|
||||
from ._migrations import monkeypatch_migrations
|
||||
|
||||
monkeypatch_migrations()
|
||||
|
||||
|
||||
class Command(Parent):
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
monkeypatch_migrations()
|
||||
return super().handle(*args, **kwargs)
|
||||
pass
|
||||
|
||||
@@ -64,7 +64,7 @@ class Command(BaseCommand):
|
||||
if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS:
|
||||
return
|
||||
|
||||
for receiver in periodic_task._live_receivers(self)[0]:
|
||||
for receiver in periodic_task._live_receivers(self):
|
||||
name = f'{receiver.__module__}.{receiver.__name__}'
|
||||
if options['list_tasks']:
|
||||
print(name)
|
||||
|
||||
@@ -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
@@ -26,7 +26,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class BaseMediaType:
|
||||
medium_created_by_server = False
|
||||
medium_created_from_unknown_supported = False
|
||||
supports_orderposition = False
|
||||
supports_giftcard = False
|
||||
|
||||
@@ -57,7 +56,7 @@ class BaseMediaType:
|
||||
def is_active(self, organizer):
|
||||
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
|
||||
|
||||
def handle_new(self, organizer, medium, user, auth):
|
||||
@@ -89,32 +88,23 @@ class NfcUidMediaType(BaseMediaType):
|
||||
verbose_name = _('NFC UID-based')
|
||||
icon = 'pretixbase/img/media/nfc_uid.svg'
|
||||
medium_created_by_server = False
|
||||
medium_created_from_unknown_supported = 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
|
||||
|
||||
create_giftcard = organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool)
|
||||
if create_giftcard or force_create:
|
||||
if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool):
|
||||
if identifier.startswith("08"):
|
||||
# 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.
|
||||
return
|
||||
with transaction.atomic():
|
||||
if create_giftcard:
|
||||
gc = GiftCard.objects.create(
|
||||
issuer=organizer,
|
||||
expires=organizer.default_gift_card_expiry,
|
||||
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
|
||||
gc = GiftCard.objects.create(
|
||||
issuer=organizer,
|
||||
expires=organizer.default_gift_card_expiry,
|
||||
currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'),
|
||||
)
|
||||
m = ReusableMedium.objects.create(
|
||||
type=self.identifier,
|
||||
identifier=identifier,
|
||||
@@ -126,6 +116,10 @@ class NfcUidMediaType(BaseMediaType):
|
||||
'pretix.reusable_medium.created.auto',
|
||||
user=user, auth=auth,
|
||||
)
|
||||
gc.log_action(
|
||||
'pretix.giftcards.created',
|
||||
user=user, auth=auth,
|
||||
)
|
||||
return m
|
||||
|
||||
|
||||
@@ -135,7 +129,7 @@ class NfcMf0aesMediaType(BaseMediaType):
|
||||
icon = 'pretixbase/img/media/nfc_secure.svg'
|
||||
medium_created_by_server = False
|
||||
supports_giftcard = True
|
||||
supports_orderposition = True
|
||||
supports_orderposition = False
|
||||
|
||||
def handle_new(self, organizer, medium, user, auth):
|
||||
from pretix.base.models import GiftCard
|
||||
|
||||
@@ -282,12 +282,10 @@ def metric_values():
|
||||
|
||||
# Throwaway metrics
|
||||
exact_tables = [
|
||||
Order, Invoice, Event, Organizer
|
||||
Order, OrderPosition, Invoice, Event, Organizer
|
||||
]
|
||||
for m in apps.get_models(): # Count all models
|
||||
if issubclass(m, OrderPosition):
|
||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.all.count()
|
||||
elif any(issubclass(m, p) for p in exact_tables):
|
||||
if any(issubclass(m, p) for p in exact_tables):
|
||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count()
|
||||
else:
|
||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = estimate_count_fast(m)
|
||||
|
||||
@@ -24,7 +24,6 @@ from urllib.parse import urlparse, urlsplit
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import BadRequest
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.middleware.common import CommonMiddleware
|
||||
from django.urls import get_script_prefix, resolve
|
||||
@@ -74,7 +73,6 @@ class LocaleMiddleware(MiddlewareMixin):
|
||||
|
||||
def process_request(self, request: HttpRequest):
|
||||
language = get_language_from_request(request)
|
||||
region = None
|
||||
# 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
|
||||
# set and can be taken into account for the decision.
|
||||
@@ -95,16 +93,15 @@ class LocaleMiddleware(MiddlewareMixin):
|
||||
if '-' not in language and settings_holder.settings.region:
|
||||
language += '-' + settings_holder.settings.region
|
||||
if settings_holder.settings.region:
|
||||
region = settings_holder.settings.region
|
||||
set_region(settings_holder.settings.region)
|
||||
else:
|
||||
gs = global_settings_object(request)
|
||||
if '-' not in language and gs.settings.region:
|
||||
language += '-' + gs.settings.region
|
||||
if gs.settings.region:
|
||||
region = gs.settings.region
|
||||
set_region(gs.settings.region)
|
||||
|
||||
translation.activate(language)
|
||||
set_region(region)
|
||||
request.LANGUAGE_CODE = get_language_without_region()
|
||||
|
||||
tzname = None
|
||||
@@ -283,7 +280,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
h = {
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ["{static}"],
|
||||
'script-src': ['{static}'],
|
||||
'object-src': ["'none'"],
|
||||
'frame-src': ['{static}'],
|
||||
'style-src': ["{static}", "{media}"],
|
||||
@@ -297,18 +294,6 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
# this. However, we'll restrict it to HTTPS.
|
||||
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
|
||||
}
|
||||
|
||||
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"]
|
||||
|
||||
if hasattr(request, 'csp_nonce'):
|
||||
nonce = f"'nonce-{request.csp_nonce}'"
|
||||
h['script-src'].append(nonce)
|
||||
if not settings.VITE_DEV_MODE:
|
||||
# can't have 'unsafe-inline' and nonce at the same time
|
||||
h['style-src'].append(nonce)
|
||||
# Only include pay.google.com for wallet detection purposes on the Payment selection page
|
||||
if (
|
||||
url.url_name == "event.order.pay.change" or
|
||||
@@ -362,18 +347,6 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
return resp
|
||||
|
||||
|
||||
class RejectInvalidInputMiddleware(MiddlewareMixin):
|
||||
|
||||
def process_request(self, request):
|
||||
# Nullbytes in GET/POST parameters are mostly harmless, as they will later fail on database insertion, but it
|
||||
# keeps spamming our error logs whenever someone tries to run a vulnerability scanner.
|
||||
if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']:
|
||||
raise BadRequest("Invalid characters in input.")
|
||||
if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded":
|
||||
if any("\x00" in value for key, value_list in request.POST.lists() for value in value_list):
|
||||
raise BadRequest("Invalid characters in input.")
|
||||
|
||||
|
||||
class CustomCommonMiddleware(CommonMiddleware):
|
||||
|
||||
def get_full_path_with_slash(self, request):
|
||||
|
||||
@@ -41,20 +41,16 @@ class Migration(migrations.Migration):
|
||||
name='datetime',
|
||||
field=models.DateTimeField(),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
'logentry',
|
||||
models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b1fe5a_idx"),
|
||||
migrations.AlterIndexTogether(
|
||||
name='logentry',
|
||||
index_together={('datetime', 'id')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
'order',
|
||||
models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"),
|
||||
migrations.AlterIndexTogether(
|
||||
name='order',
|
||||
index_together={('datetime', 'id'), ('last_modified', 'id')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
'order',
|
||||
models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
'transaction',
|
||||
models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b20405_idx"),
|
||||
migrations.AlterIndexTogether(
|
||||
name='transaction',
|
||||
index_together={('datetime', 'id')},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -61,10 +61,7 @@ class Migration(migrations.Migration):
|
||||
options={
|
||||
'ordering': ('identifier', 'type', 'organizer'),
|
||||
'unique_together': {('identifier', 'type', 'organizer')},
|
||||
'indexes': [
|
||||
models.Index(fields=('identifier', 'type', 'organizer'), name='reusable_medium_organizer_index'),
|
||||
models.Index(fields=('updated', 'id'), name="pretixbase__updated_093277_idx")
|
||||
],
|
||||
'index_together': {('identifier', 'type', 'organizer'), ('updated', 'id')},
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,31 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name="logentry",
|
||||
new_name="pretixbase__datetim_b1fe5a_idx",
|
||||
old_fields=("datetime", "id"),
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name="order",
|
||||
new_name="pretixbase__datetim_66aff0_idx",
|
||||
old_fields=("datetime", "id"),
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name="order",
|
||||
new_name="pretixbase__last_mo_4ebf8b_idx",
|
||||
old_fields=("last_modified", "id"),
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name="reusablemedium",
|
||||
new_name="pretixbase__updated_093277_idx",
|
||||
old_fields=("updated", "id"),
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name="transaction",
|
||||
new_name="pretixbase__datetim_b20405_idx",
|
||||
old_fields=("datetime", "id"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="attendeeprofile",
|
||||
name="id",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Generated by Django 4.2.10 on 2024-04-02 15:16
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -10,8 +10,8 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
"reusablemedium",
|
||||
'reusable_medium_organizer_index',
|
||||
migrations.AlterIndexTogether(
|
||||
name="reusablemedium",
|
||||
index_together=set(),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -70,10 +70,6 @@ def parse_csv(file, length=None, mode="strict", charset=None):
|
||||
except ImportError:
|
||||
charset = file.charset
|
||||
data = data.decode(charset or "utf-8", mode)
|
||||
|
||||
# remove stray linebreaks from the end of the file
|
||||
data = data.rstrip("\n")
|
||||
|
||||
# If the file was modified on a Mac, it only contains \r as line breaks
|
||||
if '\r' in data and '\n' not in data:
|
||||
data = data.replace('\r', '\n')
|
||||
|
||||
@@ -442,7 +442,7 @@ class AttendeeState(ImportColumn):
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Attendee address') + ': ' + pgettext('address', 'State')
|
||||
return _('Attendee address') + ': ' + _('State')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
|
||||
@@ -29,9 +29,7 @@ import inspect
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
@@ -76,14 +74,10 @@ def _transactions_mark_order_dirty(order_id, using=None):
|
||||
if "PYTEST_CURRENT_TEST" in os.environ:
|
||||
# We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code
|
||||
# or not.
|
||||
for frame in inspect.stack()[1:]:
|
||||
if (
|
||||
'pretix/base/models/orders' in frame.filename
|
||||
or Path(frame.filename).is_relative_to(Path(django.__file__).parent)
|
||||
):
|
||||
# Ignore model- and django-internal code
|
||||
for frame in inspect.stack():
|
||||
if 'pretix/base/models/orders' in frame.filename:
|
||||
continue
|
||||
elif 'test_' in frame.filename or 'conftest.py' in frame.filename:
|
||||
elif 'test_' in frame.filename or 'conftest.py in frame.filename':
|
||||
return
|
||||
elif 'pretix/' in frame.filename or 'pretix_' in frame.filename:
|
||||
# This went through non-test code, let's consider it non-test
|
||||
|
||||
@@ -38,7 +38,6 @@ import operator
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from functools import reduce
|
||||
from typing import Protocol
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import (
|
||||
@@ -57,7 +56,7 @@ from django_otp.models import Device
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.helpers.urls import mainreverse_absolute
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
from ...helpers.countries import FastCountryField
|
||||
from ...helpers.u2f import pub_key_from_der, websafe_decode
|
||||
@@ -68,14 +67,6 @@ class EmailAddressTakenError(IntegrityError):
|
||||
pass
|
||||
|
||||
|
||||
class PermissionHolder(Protocol):
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||
...
|
||||
|
||||
def has_organizer_permission(self, organizer, perm_name=None, request=None):
|
||||
...
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
"""
|
||||
This is the user manager for our custom user model. See the User
|
||||
@@ -378,7 +369,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
{
|
||||
'user': self,
|
||||
'messages': msg,
|
||||
'url': mainreverse_absolute('control:user.settings'),
|
||||
'url': build_absolute_uri('control:user.settings'),
|
||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||
},
|
||||
event=None,
|
||||
@@ -466,7 +457,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
{
|
||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||
'user': self,
|
||||
'url': (mainreverse_absolute('control:auth.forgot.recover')
|
||||
'url': (build_absolute_uri('control:auth.forgot.recover')
|
||||
+ '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self)))
|
||||
},
|
||||
None, locale=self.locale, user=self
|
||||
@@ -705,18 +696,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
return self.teams.exists()
|
||||
|
||||
|
||||
class UserWithStaffSession:
|
||||
# Wrapper around a User object with a staff session, implementing the PermissionHolder Protocol
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||
return True
|
||||
|
||||
def has_organizer_permission(self, organizer, perm_name=None, request=None):
|
||||
return True
|
||||
|
||||
|
||||
class UserKnownLoginSource(models.Model):
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
|
||||
agent_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
@@ -125,7 +125,7 @@ class LoggingMixin:
|
||||
elif isinstance(self, Event):
|
||||
event = self
|
||||
organizer_id = self.organizer_id
|
||||
elif hasattr(self, 'event') and self.event:
|
||||
elif hasattr(self, 'event'):
|
||||
event = self.event
|
||||
organizer_id = self.event.organizer_id
|
||||
elif hasattr(self, 'organizer_id'):
|
||||
|
||||
@@ -346,14 +346,11 @@ class Checkin(models.Model):
|
||||
REASON_INCOMPLETE = 'incomplete'
|
||||
REASON_ALREADY_REDEEMED = 'already_redeemed'
|
||||
REASON_AMBIGUOUS = 'ambiguous'
|
||||
REASON_MEDIUM_INVALID = 'medium_invalid'
|
||||
REASON_MEDIUM_EXISTS = 'medium_exists'
|
||||
REASON_ERROR = 'error'
|
||||
REASON_BLOCKED = 'blocked'
|
||||
REASON_UNAPPROVED = 'unapproved'
|
||||
REASON_INVALID_TIME = 'invalid_time'
|
||||
REASON_ANNULLED = 'annulled'
|
||||
REASON_ALREADY_EXCHANGED = 'already_exchanged'
|
||||
REASONS = (
|
||||
(REASON_CANCELED, _('Order canceled')),
|
||||
(REASON_INVALID, _('Unknown ticket')),
|
||||
@@ -369,9 +366,6 @@ class Checkin(models.Model):
|
||||
(REASON_UNAPPROVED, _('Order not approved')),
|
||||
(REASON_INVALID_TIME, _('Ticket not valid at this time')),
|
||||
(REASON_ANNULLED, _('Check-in annulled')),
|
||||
(REASON_ALREADY_EXCHANGED, _('Ticket already exchanged')),
|
||||
(REASON_MEDIUM_INVALID, _('Reusable medium invalid')),
|
||||
(REASON_MEDIUM_EXISTS, _('Reusable medium already exists')),
|
||||
)
|
||||
|
||||
successful = models.BooleanField(
|
||||
|
||||
@@ -167,7 +167,7 @@ class Customer(LoggedModel):
|
||||
|
||||
def send_security_notice(self, message, email=None):
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
try:
|
||||
with language(self.locale):
|
||||
@@ -178,7 +178,7 @@ class Customer(LoggedModel):
|
||||
{
|
||||
**self.get_email_context(),
|
||||
'message': str(message),
|
||||
'url': eventreverse_absolute(self.organizer, 'presale:organizer.customer.index')
|
||||
'url': build_absolute_uri(self.organizer, 'presale:organizer.customer.index')
|
||||
},
|
||||
customer=self,
|
||||
organizer=self.organizer,
|
||||
@@ -299,12 +299,12 @@ class Customer(LoggedModel):
|
||||
|
||||
def send_activation_mail(self):
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.presale.forms.customer import TokenGenerator
|
||||
|
||||
ctx = self.get_email_context()
|
||||
token = TokenGenerator().make_token(self)
|
||||
ctx['url'] = eventreverse_absolute(
|
||||
ctx['url'] = build_absolute_uri(
|
||||
self.organizer,
|
||||
'presale:organizer.customer.activate'
|
||||
) + '?id=' + self.identifier + '&token=' + token
|
||||
|
||||
@@ -229,7 +229,7 @@ class Device(LoggedModel):
|
||||
"""
|
||||
return self._organizer_permission_set() if self.organizer == organizer else set()
|
||||
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
|
||||
"""
|
||||
Checks if this token is part of a team that grants access of type ``perm_name``
|
||||
to the event ``event``.
|
||||
@@ -238,7 +238,6 @@ class Device(LoggedModel):
|
||||
:param event: The event to check
|
||||
:param perm_name: The permission, e.g. ``event.orders:read``
|
||||
:param request: This parameter is ignored and only defined for compatibility reasons.
|
||||
:param session_key: This parameter is ignored and only defined for compatibility reasons.
|
||||
:return: bool
|
||||
"""
|
||||
has_event_access = (self.all_events and organizer == self.organizer) or (
|
||||
|
||||
@@ -715,16 +715,10 @@ class Event(EventMixin, LoggedModel):
|
||||
self.settings.name_scheme = 'given_family'
|
||||
self.settings.payment_banktransfer_invoice_immediately = True
|
||||
self.settings.low_availability_percentage = 10
|
||||
self.settings.mail_send_order_free_attendee = True
|
||||
self.settings.mail_send_order_placed_attendee = True
|
||||
self.settings.mail_send_order_paid_attendee = True
|
||||
self.settings.mail_send_order_approved_attendee = True
|
||||
self.settings.mail_send_order_approved_free_attendee = True
|
||||
self.settings.mail_text_download_reminder_attendee = True
|
||||
|
||||
@property
|
||||
def social_image(self):
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
img = None
|
||||
logo_file = self.settings.get('logo_image', as_type=str, default='')[7:]
|
||||
@@ -742,7 +736,7 @@ class Event(EventMixin, LoggedModel):
|
||||
logger.exception(f'Failed to create thumbnail of {logo_file}')
|
||||
img = default_storage.url(logo_file)
|
||||
if img:
|
||||
return urljoin(eventreverse_absolute(self, 'presale:event.index'), img)
|
||||
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
|
||||
|
||||
def _seats(self, ignore_voucher=None):
|
||||
from .seating import Seat
|
||||
@@ -883,8 +877,6 @@ class Event(EventMixin, LoggedModel):
|
||||
ItemProgramTime, ItemVariationMetaValue, Question, Quota,
|
||||
)
|
||||
|
||||
is_cross_organizer = other.organizer_id != self.organizer_id
|
||||
|
||||
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
|
||||
# Plugins can create data in installed() hook based on existing data of the event.
|
||||
# Calling set_active_plugins() results in defaults being created while actually data
|
||||
@@ -913,15 +905,6 @@ class Event(EventMixin, LoggedModel):
|
||||
for emv in EventMetaValue.objects.filter(event=other):
|
||||
emv.pk = None
|
||||
emv.event = self
|
||||
if is_cross_organizer:
|
||||
try:
|
||||
emv.property = self.organizer.meta_properties.get(name=emv.property.name)
|
||||
except EventMetaProperty.DoesNotExist:
|
||||
meta_prop = emv.property
|
||||
meta_prop.pk = None
|
||||
meta_prop.organizer = self.organizer
|
||||
meta_prop.save(force_insert=True)
|
||||
emv.property = meta_prop
|
||||
emv.save(force_insert=True)
|
||||
|
||||
for fl in EventFooterLink.objects.filter(event=other):
|
||||
@@ -975,13 +958,13 @@ class Event(EventMixin, LoggedModel):
|
||||
if i.tax_rule_id:
|
||||
i.tax_rule = tax_map[i.tax_rule_id]
|
||||
|
||||
if i.grant_membership_type and is_cross_organizer:
|
||||
if i.grant_membership_type and other.organizer_id != self.organizer_id:
|
||||
i.grant_membership_type = None
|
||||
|
||||
i.save() # no force_insert since i.picture.save could have already inserted
|
||||
i.log_action('pretix.object.cloned')
|
||||
|
||||
if require_membership_types and not is_cross_organizer:
|
||||
if require_membership_types and other.organizer_id == self.organizer_id:
|
||||
i.require_membership_types.set(require_membership_types)
|
||||
|
||||
if not i.all_sales_channels:
|
||||
@@ -996,7 +979,7 @@ class Event(EventMixin, LoggedModel):
|
||||
v._prefetched_objects_cache = {}
|
||||
v.save(force_insert=True)
|
||||
|
||||
if require_membership_types and not is_cross_organizer:
|
||||
if require_membership_types and other.organizer_id == self.organizer_id:
|
||||
v.require_membership_types.set(require_membership_types)
|
||||
if not v.all_sales_channels:
|
||||
v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
|
||||
@@ -1880,8 +1863,6 @@ class EventMetaValue(LoggedModel):
|
||||
self.event.cache.clear()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.event and self.event.organizer != self.property.organizer:
|
||||
raise ValidationError(_("Property and event must belong to the same organizer."))
|
||||
super().save(*args, **kwargs)
|
||||
if self.event:
|
||||
self.event.cache.clear()
|
||||
|
||||
@@ -452,16 +452,11 @@ class Item(LoggedModel):
|
||||
MEDIA_POLICY_REUSE = 'reuse'
|
||||
MEDIA_POLICY_NEW = 'new'
|
||||
MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new'
|
||||
MEDIA_POLICY_APPEND = 'append'
|
||||
MEDIA_POLICY_APPEND_OR_NEW = 'append_or_new'
|
||||
MEDIA_POLICIES = (
|
||||
(None, _("Don't use reusable media, use regular one-off tickets")),
|
||||
(None, _("Don't use re-usable media, use regular one-off tickets")),
|
||||
(MEDIA_POLICY_REUSE, _('Require an existing medium to be re-used')),
|
||||
(MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')),
|
||||
(MEDIA_POLICY_REUSE, _('Require an existing medium to be reused, replacing any previous tickets')),
|
||||
(MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used, replacing any previous tickets')),
|
||||
(MEDIA_POLICY_APPEND, _('Require an existing medium to be reused, adding to any previous tickets')),
|
||||
(MEDIA_POLICY_APPEND_OR_NEW,
|
||||
_('Require either an existing or a new medium to be used, adding to any previous tickets')),
|
||||
(MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used')),
|
||||
)
|
||||
|
||||
objects = ItemQuerySetManager()
|
||||
@@ -774,7 +769,7 @@ class Item(LoggedModel):
|
||||
null=True, blank=True, max_length=16,
|
||||
verbose_name=_('Reusable media policy'),
|
||||
help_text=_(
|
||||
'If this product should be stored on a reusable physical medium, you can attach a physical media policy. '
|
||||
'If this product should be stored on a re-usable physical medium, you can attach a physical media policy. '
|
||||
'This is not required for regular tickets, which just use a one-time barcode, but only for products like '
|
||||
'renewable season tickets or re-chargeable gift card wristbands. '
|
||||
'This is an advanced feature that also requires specific configuration of ticketing and printing settings.'
|
||||
@@ -783,7 +778,7 @@ class Item(LoggedModel):
|
||||
media_type = models.CharField(
|
||||
max_length=100,
|
||||
null=True, blank=True,
|
||||
choices=[(None, _("Don't use reusable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()],
|
||||
choices=[(None, _("Don't use re-usable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()],
|
||||
verbose_name=_('Reusable media type'),
|
||||
help_text=_(
|
||||
'Select the type of physical medium that should be used for this product. Note that not all media types '
|
||||
@@ -1000,11 +995,6 @@ class Item(LoggedModel):
|
||||
raise ValidationError(_('The selected media type does not support usage for tickets currently.'))
|
||||
if not mt.supports_giftcard and issue_giftcard:
|
||||
raise ValidationError(_('The selected media type does not support usage for gift cards currently.'))
|
||||
if media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
||||
if not mt.medium_created_by_server and not mt.medium_created_from_unknown_supported:
|
||||
raise ValidationError(_('The selected media type requires all media to be registered in the system '
|
||||
'prior to their usage. Therefore, the selected media policy does not make '
|
||||
'sense for this media type.'))
|
||||
if issue_giftcard:
|
||||
raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, '
|
||||
'gift cards for some reusable media types can be created or re-charged directly '
|
||||
@@ -2230,7 +2220,7 @@ class Quota(LoggedModel):
|
||||
class ItemMetaProperty(LoggedModel):
|
||||
"""
|
||||
An event can have ItemMetaProperty objects attached to define meta information fields
|
||||
for its items. This information can be reused for example in ticket layouts.
|
||||
for its items. This information can be re-used for example in ticket layouts.
|
||||
|
||||
:param event: The event this property is defined for.
|
||||
:type event: Event
|
||||
@@ -2316,17 +2306,10 @@ class ItemProgramTime(models.Model):
|
||||
:type start: datetime
|
||||
:param end: The date and time this program time ends
|
||||
:type end: datetime
|
||||
:param location: venue
|
||||
:type location: str
|
||||
"""
|
||||
item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE)
|
||||
start = models.DateTimeField(verbose_name=_("Start"))
|
||||
end = models.DateTimeField(verbose_name=_("End"))
|
||||
location = I18nTextField(
|
||||
null=True, blank=True,
|
||||
max_length=200,
|
||||
verbose_name=_("Location"),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
if hasattr(self, 'item') and self.item and self.item.event.has_subevents:
|
||||
|
||||
@@ -88,7 +88,7 @@ class LogEntry(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ('-datetime', '-id')
|
||||
indexes = [models.Index(fields=["datetime", "id"], name="pretixbase__datetim_b1fe5a_idx")]
|
||||
indexes = [models.Index(fields=["datetime", "id"])]
|
||||
|
||||
def display(self):
|
||||
from pretix.base.logentrytype_registry import log_entry_types
|
||||
|
||||
@@ -72,16 +72,6 @@ class ReusableMedium(LoggedModel):
|
||||
max_length=200,
|
||||
verbose_name=pgettext_lazy('reusable_medium', 'Identifier'),
|
||||
)
|
||||
claim_token = models.CharField(
|
||||
max_length=200,
|
||||
verbose_name=pgettext_lazy('reusable_medium', 'Claim token'),
|
||||
null=True, blank=True
|
||||
)
|
||||
label = models.CharField(
|
||||
max_length=200,
|
||||
verbose_name=pgettext_lazy('reusable_medium', 'Label'),
|
||||
null=True, blank=True
|
||||
)
|
||||
|
||||
active = models.BooleanField(
|
||||
verbose_name=_('Active'),
|
||||
@@ -99,14 +89,12 @@ class ReusableMedium(LoggedModel):
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_('Customer account'),
|
||||
)
|
||||
linked_orderpositions = models.ManyToManyField(
|
||||
linked_orderposition = models.ForeignKey(
|
||||
OrderPosition,
|
||||
null=True, blank=True,
|
||||
related_name='linked_media',
|
||||
verbose_name=_('Linked tickets'),
|
||||
help_text=_(
|
||||
'If you link to more than one ticket, make sure there is no overlap in validity. '
|
||||
'If multiple tickets are valid at once, this will lead to failed check-ins.'
|
||||
)
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_('Linked ticket'),
|
||||
)
|
||||
linked_giftcard = models.ForeignKey(
|
||||
GiftCard,
|
||||
@@ -129,15 +117,12 @@ class ReusableMedium(LoggedModel):
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
return self.expires and self.expires < now()
|
||||
|
||||
def touch(self):
|
||||
self.save(update_fields=['updated'])
|
||||
return self.expires and self.expires > now()
|
||||
|
||||
class Meta:
|
||||
unique_together = (("identifier", "type", "organizer"),)
|
||||
indexes = [
|
||||
models.Index(fields=("updated", "id"), name="pretixbase__updated_093277_idx"),
|
||||
models.Index(fields=("updated", "id")),
|
||||
]
|
||||
ordering = "identifier", "type", "organizer"
|
||||
|
||||
|
||||
@@ -336,8 +336,8 @@ class Order(LockModel, LoggedModel):
|
||||
verbose_name_plural = _("Orders")
|
||||
ordering = ("-datetime", "-pk")
|
||||
indexes = [
|
||||
models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"),
|
||||
models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"),
|
||||
models.Index(fields=["datetime", "id"]),
|
||||
models.Index(fields=["last_modified", "id"]),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
|
||||
@@ -354,60 +354,38 @@ class Order(LockModel, LoggedModel):
|
||||
def _transaction_key_reset(self):
|
||||
self.__initial_status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval
|
||||
|
||||
@classmethod
|
||||
def gracefully_delete_bulk(cls, event, orders, user=None, auth=None):
|
||||
# Expects to be called in a transaction
|
||||
from . import (
|
||||
GiftCard, GiftCardTransaction, LogEntry, Membership, Voucher,
|
||||
)
|
||||
|
||||
if not transaction.get_connection().in_atomic_block:
|
||||
raise Exception('gracefully_delete_bulk should only be called in atomic transaction!')
|
||||
|
||||
logs_create = []
|
||||
for o in orders:
|
||||
if not o.testmode:
|
||||
raise TypeError("Only test mode orders can be deleted.")
|
||||
order_gracefully_delete.send(event, order=o)
|
||||
logs_create.append(o.log_action(
|
||||
'pretix.event.order.deleted', user=user, auth=auth,
|
||||
data={
|
||||
'code': o.code,
|
||||
},
|
||||
save=False,
|
||||
))
|
||||
LogEntry.bulk_create_and_postprocess(logs_create)
|
||||
|
||||
voucher_ids = OrderPosition.objects.filter(
|
||||
order__in=orders,
|
||||
voucher__isnull=False
|
||||
).exclude(order__status=Order.STATUS_CANCELED).values_list("voucher_id", flat=True)
|
||||
voucher_usages = Counter(voucher_ids)
|
||||
for v_id, usage_count in voucher_usages.items():
|
||||
Voucher.objects.filter(pk=v_id).update(redeemed=Greatest(0, F('redeemed') - usage_count))
|
||||
|
||||
GiftCardTransaction.objects.filter(payment__order__in=orders).update(payment=None)
|
||||
GiftCardTransaction.objects.filter(refund__order__in=orders).update(refund=None)
|
||||
GiftCardTransaction.objects.filter(order__in=orders).update(order=None)
|
||||
GiftCard.objects.filter(issued_in__order__in=orders).update(issued_in=None)
|
||||
Membership.objects.filter(granted_in__order__in=orders, testmode=True).update(granted_in=None)
|
||||
OrderPosition.all.filter(order__in=orders, addon_to__isnull=False).delete()
|
||||
OrderPosition.all.filter(order__in=orders).delete()
|
||||
OrderFee.all.filter(order__in=orders).delete()
|
||||
Transaction.objects.filter(order__in=orders).delete()
|
||||
OrderRefund.objects.filter(order__in=orders).delete()
|
||||
OrderPayment.objects.filter(order__in=orders).delete()
|
||||
if isinstance(orders, models.QuerySet):
|
||||
orders.delete()
|
||||
else:
|
||||
Order.objects.filter(pk__in=[o.pk for o in orders]).delete()
|
||||
event.cache.delete('complain_testmode_orders')
|
||||
|
||||
def gracefully_delete(self, user=None, auth=None):
|
||||
from . import GiftCard, GiftCardTransaction, Membership, Voucher
|
||||
|
||||
if not self.testmode:
|
||||
raise TypeError("Only test mode orders can be deleted.")
|
||||
self.log_action(
|
||||
'pretix.event.order.deleted', user=user, auth=auth,
|
||||
data={
|
||||
'code': self.code,
|
||||
}
|
||||
)
|
||||
|
||||
Order.gracefully_delete_bulk(self.event, Order.objects.filter(pk=self.pk), user, auth)
|
||||
order_gracefully_delete.send(self.event, order=self)
|
||||
|
||||
if self.status != Order.STATUS_CANCELED:
|
||||
for position in self.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
|
||||
GiftCardTransaction.objects.filter(payment__in=self.payments.all()).update(payment=None)
|
||||
GiftCardTransaction.objects.filter(refund__in=self.refunds.all()).update(refund=None)
|
||||
GiftCardTransaction.objects.filter(order=self).update(order=None)
|
||||
GiftCard.objects.filter(issued_in__in=self.positions.all()).update(issued_in=None)
|
||||
Membership.objects.filter(granted_in__order=self, testmode=True).update(granted_in=None)
|
||||
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
|
||||
OrderPosition.all.filter(order=self).delete()
|
||||
OrderFee.all.filter(order=self).delete()
|
||||
Transaction.objects.filter(order=self).delete()
|
||||
self.refunds.all().delete()
|
||||
self.payments.all().delete()
|
||||
self.event.cache.delete('complain_testmode_orders')
|
||||
self.delete()
|
||||
|
||||
def email_confirm_secret(self):
|
||||
return self.tagged_secret("email_confirm", 9)
|
||||
@@ -612,7 +590,7 @@ class Order(LockModel, LoggedModel):
|
||||
not kwargs.get('force_save_with_deferred_fields', None) and
|
||||
(not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields))
|
||||
):
|
||||
_fail("It is unsafe to call save() on an Order with deferred fields since we can't check if you missed "
|
||||
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
|
||||
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
|
||||
"this.")
|
||||
|
||||
@@ -2863,7 +2841,7 @@ class OrderPosition(AbstractPosition):
|
||||
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
|
||||
_transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
|
||||
elif not kwargs.get('force_save_with_deferred_fields', None):
|
||||
_fail("It is unsafe to call save() on an OrderPosition with deferred fields since we can't check if you missed "
|
||||
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
|
||||
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
|
||||
"this.")
|
||||
|
||||
@@ -3102,7 +3080,7 @@ class Transaction(models.Model):
|
||||
class Meta:
|
||||
ordering = 'datetime', 'pk'
|
||||
indexes = [
|
||||
models.Index(fields=['datetime', 'id'], name="pretixbase__datetim_b20405_idx")
|
||||
models.Index(fields=['datetime', 'id'])
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -319,9 +319,6 @@ class TeamQuerySet(models.QuerySet):
|
||||
def event_permission_q(cls, perm_name):
|
||||
from ..permissions import assert_valid_event_permission
|
||||
|
||||
if perm_name is None:
|
||||
return Q()
|
||||
|
||||
if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_EVENT_COMPAT: # legacy
|
||||
return reduce(operator.and_, [cls.event_permission_q(p) for p in OLD_TO_NEW_EVENT_COMPAT[perm_name]])
|
||||
assert_valid_event_permission(perm_name, allow_legacy=False)
|
||||
@@ -334,9 +331,6 @@ class TeamQuerySet(models.QuerySet):
|
||||
def organizer_permission_q(cls, perm_name):
|
||||
from ..permissions import assert_valid_organizer_permission
|
||||
|
||||
if perm_name is None:
|
||||
return Q()
|
||||
|
||||
if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_ORGANIZER_COMPAT: # legacy
|
||||
return reduce(operator.and_, [cls.organizer_permission_q(p) for p in OLD_TO_NEW_ORGANIZER_COMPAT[perm_name]])
|
||||
assert_valid_organizer_permission(perm_name, allow_legacy=False)
|
||||
@@ -556,7 +550,7 @@ class TeamAPIToken(models.Model):
|
||||
"""
|
||||
return self.team.organizer_permission_set() if self.team.organizer == organizer else set()
|
||||
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
|
||||
"""
|
||||
Checks if this token is part of a team that grants access of type ``perm_name``
|
||||
to the event ``event``.
|
||||
@@ -565,7 +559,6 @@ class TeamAPIToken(models.Model):
|
||||
:param event: The event to check
|
||||
:param perm_name: The permission, e.g. ``event.orders:read``
|
||||
:param request: This parameter is ignored and only defined for compatibility reasons.
|
||||
:param session_key: This parameter is ignored and only defined for compatibility reasons.
|
||||
:return: bool
|
||||
"""
|
||||
has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
|
||||
|
||||
@@ -118,10 +118,7 @@ class SeatingPlan(LoggedModel):
|
||||
for zi, z in enumerate(self.layout_data['zones']):
|
||||
zpos = (z['position']['x'], z['position']['y'])
|
||||
for ri, r in enumerate(z['rows']):
|
||||
rpos = (
|
||||
zpos[0] + r.get('position', {}).get('x', 0),
|
||||
zpos[1] + r.get('position', {}).get('y', 0),
|
||||
)
|
||||
rpos = (zpos[0] + r['position']['x'], zpos[1] + r['position']['y'])
|
||||
row_label = None
|
||||
if r.get('row_label'):
|
||||
row_label = r['row_label'].replace("%s", r.get('row_number', str(ri)))
|
||||
@@ -150,8 +147,8 @@ class SeatingPlan(LoggedModel):
|
||||
zone=z['name'],
|
||||
category=s['category'],
|
||||
sorting_rank=rank,
|
||||
x=rpos[0] + s.get('position', {}).get('x', 0),
|
||||
y=rpos[1] + s.get('position', {}).get('y', 0),
|
||||
x=rpos[0] + s['position']['x'],
|
||||
y=rpos[1] + s['position']['y'],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from pretix.base.models import Event, LogEntry
|
||||
from pretix.base.signals import register_notification_types
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.helpers.urls import mainreverse_absolute
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_ALL_TYPES = None
|
||||
@@ -170,7 +170,7 @@ class ParametrizedOrderNotificationType(NotificationType):
|
||||
def build_notification(self, logentry: LogEntry):
|
||||
order = logentry.content_object
|
||||
|
||||
order_url = mainreverse_absolute(
|
||||
order_url = build_absolute_uri(
|
||||
'control:event.order',
|
||||
kwargs={
|
||||
'organizer': logentry.event.organizer.slug,
|
||||
|
||||
@@ -71,7 +71,7 @@ from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.format import format_map
|
||||
from pretix.helpers.money import DecimalTextInput
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.presale.views import get_cart
|
||||
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
|
||||
|
||||
@@ -379,7 +379,7 @@ class BasePaymentProvider:
|
||||
|
||||
if not self.settings.get('_hidden_seed'):
|
||||
self.settings.set('_hidden_seed', get_random_string(64))
|
||||
hidden_url = eventreverse_absolute(self.event, 'presale:event.payment.unlock', kwargs={
|
||||
hidden_url = build_absolute_uri(self.event, 'presale:event.payment.unlock', kwargs={
|
||||
'hash': hashlib.sha256((self.settings._hidden_seed + self.event.slug).encode()).hexdigest(),
|
||||
})
|
||||
|
||||
|
||||
+15
-37
@@ -54,7 +54,7 @@ from bidi import get_display
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Exists, Max, Min, OuterRef
|
||||
from django.db.models import Max, Min
|
||||
from django.db.models.fields.files import FieldFile
|
||||
from django.dispatch import receiver
|
||||
from django.utils.deconstruct import deconstructible
|
||||
@@ -76,7 +76,7 @@ from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.platypus import Paragraph
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Checkin, Event, Order, OrderPosition, Question
|
||||
from pretix.base.models import Event, Order, OrderPosition, Question
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import layout_image_variables, layout_text_variables
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
@@ -372,11 +372,6 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": _("Atlantis"),
|
||||
"evaluate": lambda op, order, ev: str(getattr(order.invoice_address.country, 'name', '')) if getattr(order, 'invoice_address', None) else ''
|
||||
}),
|
||||
("invoice_custom_field", {
|
||||
"label": _("Invoice custom recipient field"),
|
||||
"editor_sample": _("Custom recipient field"),
|
||||
"evaluate": lambda op, order, ev: order.invoice_address.custom_field if getattr(order, 'invoice_address', None) else ''
|
||||
}),
|
||||
("addons", {
|
||||
"label": _("List of Add-Ons"),
|
||||
"editor_sample": _("Add-on 1\n2x Add-on 2"),
|
||||
@@ -384,13 +379,6 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
str(p) for p in generate_compressed_addon_list(op, order, ev)
|
||||
])
|
||||
}),
|
||||
("checked_in_addons", {
|
||||
"label": _("List of Checked-In Add-Ons"),
|
||||
"editor_sample": _("Add-on 1\n2x Add-on 2"),
|
||||
"evaluate": lambda op, order, ev: "\n".join([
|
||||
str(p) for p in generate_compressed_addon_list(op, order, ev, only_checked_in=True)
|
||||
])
|
||||
}),
|
||||
("organizer", {
|
||||
"label": _("Organizer name"),
|
||||
"editor_sample": _("Event organizer company"),
|
||||
@@ -503,9 +491,9 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
) if op.valid_until else ""
|
||||
}),
|
||||
("program_times", {
|
||||
"label": _("Program times"),
|
||||
"label": _("Program times: date and time"),
|
||||
"editor_sample": _(
|
||||
"2017-05-31 10:00 – 12:00, Room 1\n2017-05-31 14:00 – 16:00, Room 2\n2017-05-31 14:00 – 2017-06-01 14:00, Building A"),
|
||||
"2017-05-31 10:00 – 12:00\n2017-05-31 14:00 – 16:00\n2017-05-31 14:00 – 2017-06-01 14:00"),
|
||||
"evaluate": lambda op, order, ev: get_program_times(op, ev)
|
||||
}),
|
||||
("medium_identifier", {
|
||||
@@ -753,31 +741,21 @@ def get_seat(op: OrderPosition):
|
||||
|
||||
|
||||
def get_program_times(op: OrderPosition, ev: Event):
|
||||
ptstr = []
|
||||
for pt in op.item.program_times.all():
|
||||
ptstr.append([
|
||||
datetimerange(
|
||||
pt.start.astimezone(ev.timezone),
|
||||
pt.end.astimezone(ev.timezone),
|
||||
as_html=False
|
||||
),
|
||||
(', ' + ', '.join(
|
||||
l.strip() for l in str(pt.location).splitlines() if l.strip())
|
||||
) if str(pt.location).strip() else ''
|
||||
])
|
||||
return '\n'.join(''.join(l) for l in ptstr)
|
||||
return '\n'.join([
|
||||
datetimerange(
|
||||
pt.start.astimezone(ev.timezone),
|
||||
pt.end.astimezone(ev.timezone),
|
||||
as_html=False
|
||||
) for pt in op.item.program_times.all()
|
||||
])
|
||||
|
||||
|
||||
def generate_compressed_addon_list(op, order, event, only_checked_in=False):
|
||||
def generate_compressed_addon_list(op, order, event):
|
||||
itemcount = defaultdict(int)
|
||||
addon_qs = (
|
||||
addons = [p for p in (
|
||||
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
|
||||
else op.addons.select_related('item', 'variation')
|
||||
)
|
||||
if only_checked_in:
|
||||
addon_qs = addon_qs.filter(Exists(Checkin.objects.filter(position=OuterRef('pk'))), canceled=False)
|
||||
addons = [p for p in addon_qs if not p.canceled]
|
||||
|
||||
) if not p.canceled]
|
||||
for pos in addons:
|
||||
itemcount[pos.item, pos.variation] += 1
|
||||
|
||||
@@ -934,7 +912,7 @@ class Renderer:
|
||||
|
||||
# We do not use str.format like in emails so we (a) can evaluate lazily and (b) can re-implement this
|
||||
# 1:1 on other platforms that render PDFs through our API (libpretixprint)
|
||||
return re.sub(r'\{([-a-zA-Z0-9:_]+)\}', replace, text)
|
||||
return re.sub(r'\{([a-zA-Z0-9:_]+)\}', replace, text)
|
||||
|
||||
elif o['content'].startswith('itemmeta:'):
|
||||
if op.variation_id:
|
||||
|
||||
+24
-29
@@ -49,39 +49,14 @@ class PluginType(Enum):
|
||||
EXPORT = 4
|
||||
|
||||
|
||||
def plugin_is_available(meta, event=None, organizer=None):
|
||||
if not hasattr(meta.app, 'is_available'):
|
||||
return True
|
||||
|
||||
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
|
||||
if level == PLUGIN_LEVEL_EVENT:
|
||||
if event:
|
||||
return meta.app.is_available(event)
|
||||
elif organizer:
|
||||
if not hasattr(organizer, '_plugin_availability_fallback_event'):
|
||||
with scope(organizer=organizer):
|
||||
setattr(organizer, '_plugin_availability_fallback_event', organizer.events.first())
|
||||
return (
|
||||
organizer._plugin_availability_fallback_event
|
||||
and meta.app.is_available(organizer._plugin_availability_fallback_event)
|
||||
)
|
||||
elif level == PLUGIN_LEVEL_ORGANIZER:
|
||||
if organizer:
|
||||
return meta.app.is_available(organizer)
|
||||
elif event:
|
||||
return meta.app.is_available(event.organizer)
|
||||
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer):
|
||||
return meta.app.is_available(event or organizer)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_all_plugins(*, event=None, organizer=None) -> List[type]:
|
||||
"""
|
||||
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
|
||||
"""
|
||||
assert not event or not organizer
|
||||
plugins = []
|
||||
event_fallback = None
|
||||
event_fallback_used = False
|
||||
for app in apps.get_app_configs():
|
||||
if hasattr(app, 'PretixPluginMeta'):
|
||||
meta = app.PretixPluginMeta
|
||||
@@ -90,8 +65,28 @@ def get_all_plugins(*, event=None, organizer=None) -> List[type]:
|
||||
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
|
||||
continue
|
||||
|
||||
if not plugin_is_available(meta, event, organizer):
|
||||
continue
|
||||
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
|
||||
if level == PLUGIN_LEVEL_EVENT:
|
||||
if event and hasattr(app, 'is_available'):
|
||||
if not app.is_available(event):
|
||||
continue
|
||||
elif organizer and hasattr(app, 'is_available'):
|
||||
if not event_fallback_used:
|
||||
with scope(organizer=organizer):
|
||||
event_fallback = organizer.events.first()
|
||||
event_fallback_used = True
|
||||
if not event_fallback or not app.is_available(event_fallback):
|
||||
continue
|
||||
elif level == PLUGIN_LEVEL_ORGANIZER:
|
||||
if organizer and hasattr(app, 'is_available'):
|
||||
if not app.is_available(organizer):
|
||||
continue
|
||||
elif event and hasattr(app, 'is_available'):
|
||||
if not app.is_available(event.organizer):
|
||||
continue
|
||||
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer) and hasattr(app, 'is_available'):
|
||||
if not app.is_available(event or organizer):
|
||||
continue
|
||||
|
||||
plugins.append(meta)
|
||||
return sorted(
|
||||
|
||||
@@ -162,12 +162,12 @@ error_messages = {
|
||||
'price_too_high': gettext_lazy('The entered price is to high.'),
|
||||
'voucher_invalid': gettext_lazy('This voucher code is not known in our database.'),
|
||||
'voucher_min_usages': ngettext_lazy(
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching product.',
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
|
||||
'number'
|
||||
),
|
||||
'voucher_min_usages_removed': ngettext_lazy(
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching product. '
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
|
||||
'We have therefore removed some positions from your cart that can no longer be purchased like this.',
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
|
||||
'We have therefore removed some positions from your cart that can no longer be purchased like this.',
|
||||
@@ -287,11 +287,11 @@ def _check_position_constraints(
|
||||
raise CartPositionError(error_messages['unavailable'])
|
||||
|
||||
# Invalid media policy for online sale
|
||||
if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
||||
if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
||||
mt = MEDIA_TYPES[item.media_type]
|
||||
if not mt.medium_created_by_server:
|
||||
raise CartPositionError(error_messages['media_usage_not_implemented'])
|
||||
elif item.media_policy in (Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_APPEND):
|
||||
elif item.media_policy == Item.MEDIA_POLICY_REUSE:
|
||||
raise CartPositionError(error_messages['media_usage_not_implemented'])
|
||||
|
||||
# Item removed from sales channel
|
||||
|
||||
@@ -867,15 +867,6 @@ class RequiredQuestionsError(Exception):
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
class RequiredMediaExchangeError(Exception):
|
||||
def __init__(self, msg, code, media_policy, media_type):
|
||||
self.msg = msg
|
||||
self.code = code
|
||||
self.media_policy = media_policy
|
||||
self.media_type = media_type
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
def _save_answers(op, answers, given_answers):
|
||||
def _create_answer(question, answer):
|
||||
try:
|
||||
@@ -948,7 +939,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
|
||||
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
|
||||
raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False,
|
||||
gate=None, reusable_medium=None):
|
||||
gate=None):
|
||||
"""
|
||||
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
|
||||
not valid at this time.
|
||||
@@ -964,7 +955,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
:param datetime: The datetime of the checkin, defaults to now.
|
||||
:param simulate: If true, the check-in is not saved.
|
||||
:param gate: The gate the check-in was performed at.
|
||||
:param reusable_medium: The medium that is available for an exchange
|
||||
"""
|
||||
|
||||
# !!!!!!!!!
|
||||
@@ -1045,7 +1035,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
|
||||
with transaction.atomic():
|
||||
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
|
||||
opqs = OrderPosition.all.select_related("order", "item")
|
||||
opqs = OrderPosition.all
|
||||
if type != Checkin.TYPE_EXIT:
|
||||
opqs = opqs.select_for_update(of=OF_SELF)
|
||||
op = opqs.get(pk=op.pk)
|
||||
@@ -1111,24 +1101,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
require_answers
|
||||
)
|
||||
|
||||
required_media_policy = op.item.media_policy
|
||||
required_media_type = op.item.media_type
|
||||
require_a_medium = required_media_policy and required_media_type
|
||||
linked_media = op.linked_media
|
||||
if require_a_medium and not reusable_medium and not force:
|
||||
if not linked_media.exists():
|
||||
raise RequiredMediaExchangeError(
|
||||
_('Ticket needs to be exchanged to a suitable medium.'),
|
||||
'exchange',
|
||||
required_media_policy,
|
||||
required_media_type
|
||||
)
|
||||
elif op.organizer.settings.reusable_media_usage_enforced:
|
||||
raise CheckInError(
|
||||
_('This ticket has already been exchanged for a reusable medium that now needs to be used instead.'),
|
||||
'already_exchanged',
|
||||
)
|
||||
|
||||
device = None
|
||||
if isinstance(auth, Device):
|
||||
device = auth
|
||||
|
||||
@@ -38,7 +38,6 @@ SOURCE_NAMES = {
|
||||
None: _('European Central Bank'), # backwards-compatibility
|
||||
'eu:ecb:eurofxref-daily': _('European Central Bank'),
|
||||
'cz:cnb:rate-fixing-daily': _('Czech National Bank'),
|
||||
'pl:nbp:table-a': _('National Bank of Poland'),
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +49,6 @@ def fetch_rates(sender, **kwargs):
|
||||
source_tasks = {
|
||||
'eu:ecb:eurofxref-daily': fetch_ecb_rates,
|
||||
'cz:cnb:rate-fixing-daily': fetch_cnb_cz_rates,
|
||||
'pl:nbp:table-a': fetch_nbp_pl_rates,
|
||||
}
|
||||
|
||||
for source_name, task in source_tasks.items():
|
||||
@@ -146,29 +144,3 @@ def fetch_cnb_cz_rates():
|
||||
rate=rate,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.task()
|
||||
def fetch_nbp_pl_rates():
|
||||
"""
|
||||
Fetches currency rates from the Polish National Bank.
|
||||
"""
|
||||
r = requests.get("https://api.nbp.pl/api/exchangerates/tables/A/", headers={
|
||||
"Accept": "application/json",
|
||||
})
|
||||
r.raise_for_status()
|
||||
data = r.json()[0]
|
||||
|
||||
source_date = datetime.strptime(data["effectiveDate"], "%Y-%m-%d").date()
|
||||
|
||||
for r in data["rates"]:
|
||||
rate = Decimal(r["mid"]).quantize(Decimal('0.000001'))
|
||||
ExchangeRate.objects.update_or_create(
|
||||
source='pl:nbp:table-a',
|
||||
source_currency=r["code"],
|
||||
other_currency='PLN',
|
||||
defaults=dict(
|
||||
source_date=source_date,
|
||||
rate=rate,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -40,7 +40,6 @@ from pretix.base.models import (
|
||||
CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken,
|
||||
User, cachedfile_name,
|
||||
)
|
||||
from pretix.base.models.auth import UserWithStaffSession
|
||||
from pretix.base.models.exports import ScheduledOrganizerExport
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.services.tasks import (
|
||||
@@ -51,7 +50,7 @@ from pretix.base.signals import (
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF, repeatable_reads_transaction
|
||||
from pretix.helpers.urls import mainreverse_absolute
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -212,12 +211,7 @@ def init_event_exporters(event, user=None, token=None, device=None, request=None
|
||||
if not perm_holder.has_event_permission(event.organizer, event, permission_name, request) and not staff_session:
|
||||
continue
|
||||
|
||||
exporter: BaseExporter = response(
|
||||
event=event,
|
||||
organizer=event.organizer,
|
||||
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
|
||||
**kwargs
|
||||
)
|
||||
exporter: BaseExporter = response(event=event, organizer=event.organizer, **kwargs)
|
||||
|
||||
if not exporter.available_for_user(user if user and user.is_authenticated else None):
|
||||
continue
|
||||
@@ -249,12 +243,7 @@ def init_organizer_exporters(
|
||||
continue
|
||||
|
||||
if issubclass(response, OrganizerLevelExportMixin):
|
||||
exporter: BaseExporter = response(
|
||||
event=Event.objects.none(),
|
||||
organizer=organizer,
|
||||
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
|
||||
**kwargs,
|
||||
)
|
||||
exporter: BaseExporter = response(event=Event.objects.none(), organizer=organizer, **kwargs)
|
||||
|
||||
try:
|
||||
if not perm_holder.has_organizer_permission(organizer, response.get_required_organizer_permission(), request) and not staff_session:
|
||||
@@ -306,12 +295,7 @@ def init_organizer_exporters(
|
||||
if not _has_permission_on_any_team_cache[permission_name] and not staff_session:
|
||||
continue
|
||||
|
||||
exporter: BaseExporter = response(
|
||||
event=_event_list_cache[permission_name],
|
||||
organizer=organizer,
|
||||
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
|
||||
**kwargs,
|
||||
)
|
||||
exporter: BaseExporter = response(event=_event_list_cache[permission_name], organizer=organizer, **kwargs)
|
||||
|
||||
if not exporter.available_for_user(user if user and user.is_authenticated else None):
|
||||
continue
|
||||
@@ -455,7 +439,7 @@ def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> Non
|
||||
schedule,
|
||||
organizer,
|
||||
exporter,
|
||||
mainreverse_absolute(
|
||||
build_absolute_uri(
|
||||
'control:organizer.export',
|
||||
kwargs={
|
||||
'organizer': organizer.slug,
|
||||
@@ -481,7 +465,7 @@ def scheduled_event_export(self, event: Event, schedule: int) -> None:
|
||||
schedule,
|
||||
event,
|
||||
exporter,
|
||||
mainreverse_absolute(
|
||||
build_absolute_uri(
|
||||
'control:event.orders.export',
|
||||
kwargs={
|
||||
'event': event.slug,
|
||||
|
||||
@@ -58,7 +58,6 @@ from pretix.base.invoicing.transmission import (
|
||||
from pretix.base.models import (
|
||||
ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
|
||||
)
|
||||
from pretix.base.models.orders import OrderPayment
|
||||
from pretix.base.models.tax import EU_CURRENCIES
|
||||
from pretix.base.services.tasks import (
|
||||
TransactionAwareProfiledEventTask, TransactionAwareTask,
|
||||
@@ -103,7 +102,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
|
||||
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
|
||||
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
|
||||
if lp and lp.payment_provider and lp.state not in (OrderPayment.PAYMENT_STATE_FAILED, OrderPayment.PAYMENT_STATE_CANCELED):
|
||||
if lp and lp.payment_provider:
|
||||
if 'payment' in inspect.signature(lp.payment_provider.render_invoice_text).parameters:
|
||||
payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp))
|
||||
else:
|
||||
@@ -205,19 +204,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
|
||||
invoice.foreign_currency_rate_date = rate.source_date
|
||||
invoice.foreign_currency_source = 'cz:cnb:rate-fixing-daily'
|
||||
elif invoice.event.settings.invoice_eu_currencies == 'PLN' and invoice.event.currency != 'PLN':
|
||||
invoice.foreign_currency_display = 'PLN'
|
||||
if settings.FETCH_ECB_RATES:
|
||||
rate = ExchangeRate.objects.filter(
|
||||
source='pl:nbp:table-a',
|
||||
source_currency=invoice.event.currency,
|
||||
other_currency=invoice.foreign_currency_display,
|
||||
source_date__gt=now().date() - timedelta(days=7)
|
||||
).first()
|
||||
if rate:
|
||||
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
|
||||
invoice.foreign_currency_rate_date = rate.source_date
|
||||
invoice.foreign_currency_source = 'pl:nbp:table-a'
|
||||
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
|
||||
@@ -85,7 +85,7 @@ from pretix.helpers.format import (
|
||||
FormattedString, PlainHtmlAlternativeString, SafeFormatter, format_map,
|
||||
)
|
||||
from pretix.helpers.hierarkey import clean_filename
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.presale.ical import get_private_icals
|
||||
|
||||
logger = logging.getLogger('pretix.base.mail')
|
||||
@@ -411,7 +411,7 @@ def mail_send_task(self, **kwargs) -> bool:
|
||||
try:
|
||||
outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail)
|
||||
except OutgoingMail.DoesNotExist:
|
||||
logger.info(f"Ignoring job for non existing email {outgoing_mail}")
|
||||
logger.info(f"Ignoring job for non existing email {outgoing_mail.guid}")
|
||||
return False
|
||||
if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT:
|
||||
logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}")
|
||||
@@ -997,7 +997,7 @@ def _wrap_plain_body(content_plain, signature, event, order, position, no_order_
|
||||
body_plain += _(
|
||||
"You can view your order details at the following URL:\n{orderurl}."
|
||||
).replace("\n", "\r\n").format(
|
||||
orderurl=eventreverse_absolute(
|
||||
orderurl=build_absolute_uri(
|
||||
order.event, 'presale:event.order.position', kwargs={
|
||||
'order': order.code,
|
||||
'secret': position.web_secret,
|
||||
@@ -1013,7 +1013,7 @@ def _wrap_plain_body(content_plain, signature, event, order, position, no_order_
|
||||
body_plain += _(
|
||||
"You can view your order details at the following URL:\n{orderurl}."
|
||||
).replace("\n", "\r\n").format(
|
||||
event=event.name, orderurl=eventreverse_absolute(
|
||||
event=event.name, orderurl=build_absolute_uri(
|
||||
order.event, 'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
|
||||
@@ -23,13 +23,10 @@ import secrets
|
||||
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import Checkin, GiftCardAcceptance, Item
|
||||
from pretix.base.models.media import MediumKeySet, ReusableMedium
|
||||
from pretix.base.services.checkin import CheckInError
|
||||
from pretix.base.models import GiftCardAcceptance
|
||||
from pretix.base.models.media import MediumKeySet
|
||||
|
||||
|
||||
def create_nfc_mf0aes_keyset(organizer):
|
||||
@@ -73,174 +70,3 @@ def get_keysets_for_organizer(organizer):
|
||||
if new_set:
|
||||
sets.append(new_set)
|
||||
return sets
|
||||
|
||||
|
||||
def perform_media_exchange(organizer, media_type, identifier, link_orderposition, user, auth):
|
||||
"""
|
||||
Create or retrieve a medium, then link the order position to it. Expected to be called in a transaction.
|
||||
|
||||
:param organizer: Organizer to operate in
|
||||
:param media_type: Type of medium to operate with
|
||||
:param identifier: Identifier of the medium
|
||||
:param link_orderposition: Position to link to the medium
|
||||
:return: ReusableMedium
|
||||
"""
|
||||
medium = None
|
||||
media_policy = link_orderposition.item.media_policy
|
||||
|
||||
if media_type not in MEDIA_TYPES: # should be caught by serializer already
|
||||
raise CheckInError(
|
||||
_('Invalid medium type.'),
|
||||
Checkin.REASON_ERROR,
|
||||
reason=_('Invalid medium type.'),
|
||||
)
|
||||
|
||||
if not MEDIA_TYPES[media_type].is_active(organizer):
|
||||
raise CheckInError(
|
||||
_('Medium type is not enabled for organizer.'),
|
||||
Checkin.REASON_ERROR,
|
||||
reason=_('Medium type is not enabled for organizer.'),
|
||||
)
|
||||
|
||||
if link_orderposition.item.media_type != media_type:
|
||||
raise CheckInError(
|
||||
_('Incorrect medium type for product.'),
|
||||
Checkin.REASON_PRODUCT,
|
||||
reason=_('Incorrect medium type for product.'),
|
||||
)
|
||||
|
||||
if link_orderposition.linked_media.exists():
|
||||
raise CheckInError(
|
||||
_('Ticket is already exchanged for reusable medium.'),
|
||||
Checkin.REASON_ALREADY_EXCHANGED,
|
||||
reason=_('Ticket is already exchanged for reusable medium.'),
|
||||
)
|
||||
|
||||
if media_policy in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_NEW):
|
||||
link_action = "append"
|
||||
else:
|
||||
link_action = "replace"
|
||||
|
||||
if media_policy in (Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_APPEND):
|
||||
try:
|
||||
medium = ReusableMedium.objects.get(
|
||||
type=media_type,
|
||||
identifier=identifier,
|
||||
organizer=organizer,
|
||||
)
|
||||
except ReusableMedium.DoesNotExist:
|
||||
raise CheckInError(
|
||||
_('Reusable medium not found.'),
|
||||
Checkin.REASON_MEDIUM_INVALID,
|
||||
reason=_('Reusable medium not found.'),
|
||||
)
|
||||
else:
|
||||
if medium.is_expired or not medium.active:
|
||||
raise CheckInError(
|
||||
_('Reusable medium is inactive or expired.'),
|
||||
Checkin.REASON_MEDIUM_INVALID,
|
||||
reason=_('Reusable medium is inactive or expired.'),
|
||||
)
|
||||
|
||||
elif media_policy in (Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW):
|
||||
try:
|
||||
medium = ReusableMedium.objects.get(
|
||||
type=media_type,
|
||||
identifier=identifier,
|
||||
organizer=organizer,
|
||||
)
|
||||
except ReusableMedium.DoesNotExist:
|
||||
if not MEDIA_TYPES[media_type].medium_created_from_unknown_supported:
|
||||
raise CheckInError(
|
||||
_('Reusable medium not found and could not be created.'),
|
||||
Checkin.REASON_MEDIUM_INVALID,
|
||||
)
|
||||
|
||||
medium = MEDIA_TYPES[media_type].handle_unknown(organizer, identifier, user, auth, force_create=True)
|
||||
if not medium:
|
||||
raise CheckInError(
|
||||
_('Reusable medium not found and could not be created.'),
|
||||
Checkin.REASON_MEDIUM_INVALID,
|
||||
)
|
||||
|
||||
if medium.is_expired or not medium.active:
|
||||
raise CheckInError(
|
||||
_('Reusable medium is inactive or expired.'),
|
||||
Checkin.REASON_MEDIUM_INVALID,
|
||||
reason=_('Reusable medium is inactive or expired.'),
|
||||
)
|
||||
|
||||
elif media_policy == Item.MEDIA_POLICY_NEW:
|
||||
if not MEDIA_TYPES[media_type].medium_created_from_unknown_supported:
|
||||
raise CheckInError(
|
||||
_('Reusable medium not found and could not be created.'),
|
||||
Checkin.REASON_MEDIUM_INVALID,
|
||||
)
|
||||
try:
|
||||
medium = MEDIA_TYPES[media_type].handle_unknown(organizer, identifier, user, auth, force_create=True)
|
||||
except IntegrityError:
|
||||
raise CheckInError(
|
||||
_('Reusable medium already exists.'),
|
||||
Checkin.REASON_MEDIUM_EXISTS,
|
||||
)
|
||||
else:
|
||||
if not medium:
|
||||
raise CheckInError(
|
||||
_('Reusable medium could not be created.'),
|
||||
Checkin.REASON_MEDIUM_INVALID,
|
||||
)
|
||||
|
||||
else:
|
||||
raise CheckInError(
|
||||
_('Product does not support medium exchange.'),
|
||||
Checkin.REASON_PRODUCT,
|
||||
reason=_('Product does not support medium exchange.'),
|
||||
)
|
||||
|
||||
if link_action == 'append':
|
||||
medium.linked_orderpositions.add(link_orderposition)
|
||||
medium.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.added',
|
||||
user=user,
|
||||
auth=auth,
|
||||
data={
|
||||
'linked_orderposition': link_orderposition,
|
||||
}
|
||||
)
|
||||
elif link_action == 'replace':
|
||||
already_found = False
|
||||
for op_pk in medium.linked_orderpositions.values_list('pk', flat=True):
|
||||
if op_pk == link_orderposition.pk:
|
||||
already_found = True
|
||||
continue
|
||||
else:
|
||||
medium.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.removed',
|
||||
data={
|
||||
'linked_orderposition': op_pk,
|
||||
}
|
||||
)
|
||||
if not already_found:
|
||||
medium.linked_orderpositions.set([link_orderposition])
|
||||
medium.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.added',
|
||||
user=user,
|
||||
auth=auth,
|
||||
data={
|
||||
'linked_orderposition': link_orderposition,
|
||||
}
|
||||
)
|
||||
|
||||
link_orderposition.order.log_action(
|
||||
'pretix.reusable_medium.exchanged',
|
||||
data={
|
||||
'position': link_orderposition.pk,
|
||||
'positionid': link_orderposition.positionid,
|
||||
'medium': medium.pk,
|
||||
'medium_identifier': medium.identifier,
|
||||
'medium_type': medium.media_type.identifier,
|
||||
}
|
||||
)
|
||||
medium.touch()
|
||||
|
||||
return medium
|
||||
|
||||
@@ -37,7 +37,7 @@ from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
||||
from pretix.base.signals import notification
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.celery import get_task_priority
|
||||
from pretix.helpers.urls import mainreverse_absolute
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
|
||||
@app.task(base=TransactionAwareTask, acks_late=True, max_retries=9, default_retry_delay=900)
|
||||
@@ -136,10 +136,10 @@ def send_notification_mail(notification: Notification, user: User):
|
||||
'site_url': settings.SITE_URL,
|
||||
'color': settings.PRETIX_PRIMARY_COLOR,
|
||||
'notification': notification,
|
||||
'settings_url': mainreverse_absolute(
|
||||
'settings_url': build_absolute_uri(
|
||||
'control:user.settings.notifications',
|
||||
),
|
||||
'disable_url': mainreverse_absolute(
|
||||
'disable_url': build_absolute_uri(
|
||||
'control:user.settings.notifications.off',
|
||||
kwargs={
|
||||
'token': user.notifications_token,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user