mirror of
https://github.com/pretix/pretix.git
synced 2026-08-07 10:17:49 +00:00
Compare commits
229
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55dc2a23b4 | ||
|
|
28de4554e5 | ||
|
|
2bd5e90a86 | ||
|
|
8095134400 | ||
|
|
62f2ed55b2 | ||
|
|
3533450601 | ||
|
|
5fb827c8f5 | ||
|
|
af6727bbe2 | ||
|
|
f0d2733e8d | ||
|
|
90bca935de | ||
|
|
9c752b7263 | ||
|
|
bfdd61a14c | ||
|
|
6aca806239 | ||
|
|
8512a8c4a5 | ||
|
|
ecee6f6c30 | ||
|
|
846ec77c50 | ||
|
|
49c69c816c | ||
|
|
f3868576c5 | ||
|
|
3857361559 | ||
|
|
b0c50de331 | ||
|
|
1dad6bf801 | ||
|
|
d94d4bd57e | ||
|
|
2437f768c9 | ||
|
|
ab621890a2 | ||
|
|
a9135919a1 | ||
|
|
e2c437fd43 | ||
|
|
ba10fc8041 | ||
|
|
7732794317 | ||
|
|
8a47ba7ff5 | ||
|
|
dd2a74557d | ||
|
|
bb9c4fa18d | ||
|
|
ac356acd29 | ||
|
|
54eadaffcc | ||
|
|
617a548b19 | ||
|
|
fe0f7864e7 | ||
|
|
fc4a2f5508 | ||
|
|
4cb32a753f | ||
|
|
3ae9aabcfb | ||
|
|
3270c4e583 | ||
|
|
3b285a89dd | ||
|
|
c1683df1fd | ||
|
|
6fdcbcebd2 | ||
|
|
ddbea7c32e | ||
|
|
3f8ed0f722 | ||
|
|
388eb7de96 | ||
|
|
df4c83d849 | ||
|
|
fa4cec8a2d | ||
|
|
943b319557 | ||
|
|
28b13667ce | ||
|
|
b00d1c9156 | ||
|
|
120317a8f2 | ||
|
|
7d5b00a610 | ||
|
|
83612c7d65 | ||
|
|
7a5f96369a | ||
|
|
7fd6bf41f9 | ||
|
|
458c3d4b83 | ||
|
|
d10d061e45 | ||
|
|
d30bca50f7 | ||
|
|
3903aca7c9 | ||
|
|
493c920aba | ||
|
|
09b7bc00b0 | ||
|
|
2e195c0274 | ||
|
|
18cb9c1816 | ||
|
|
c3e0120f9f | ||
|
|
67f7fec134 | ||
|
|
c2c97f31ca | ||
|
|
fd565ecdb2 | ||
|
|
f35b13b686 | ||
|
|
550bb675f5 | ||
|
|
adc9c9d514 | ||
|
|
8441c4bc7a | ||
|
|
97ff252c09 | ||
|
|
d3ca2ac1e5 | ||
|
|
6eebaaa563 | ||
|
|
c9781f012b | ||
|
|
000bf54105 | ||
|
|
e42d3d632f | ||
|
|
3bf5a5e478 | ||
|
|
a6f31df0d4 | ||
|
|
d40492748a | ||
|
|
f4ca230af7 | ||
|
|
4fb1748bf6 | ||
|
|
9d668af102 | ||
|
|
80fd4a3b2a | ||
|
|
65bb2283d4 | ||
|
|
00751e8911 | ||
|
|
957a066475 | ||
|
|
7e077bdd7e | ||
|
|
e80d84ec3c | ||
|
|
dc3b742d8c | ||
|
|
1f5fe1b237 | ||
|
|
a8997f8971 | ||
|
|
eb068f524c | ||
|
|
f615595547 | ||
|
|
8bd78eefcf | ||
|
|
848f7fa0e5 | ||
|
|
3442a543c8 | ||
|
|
f88c24863d | ||
|
|
79c5160b57 | ||
|
|
e8492cad3c | ||
|
|
7ea5a2b59e | ||
|
|
4c373518d0 | ||
|
|
1521c0cfcd | ||
|
|
3432e62e4f | ||
|
|
736fa38ca7 | ||
|
|
d14dc4c5ff | ||
|
|
5db1a5b8af | ||
|
|
86a51afe9e | ||
|
|
ea9c85a1b4 | ||
|
|
226ff9b044 | ||
|
|
d8991d8138 | ||
|
|
83642ec9f3 | ||
|
|
9f0ce28ce4 | ||
|
|
28722fecbd | ||
|
|
10a5d4ac68 | ||
|
|
ba36f6d2ce | ||
|
|
1e301da26c | ||
|
|
d0307b9936 | ||
|
|
c083ce904a | ||
|
|
694b915d89 | ||
|
|
ea928ea7d4 | ||
|
|
36d49fbd77 | ||
|
|
f0cb451c34 | ||
|
|
2c7fcd0599 | ||
|
|
034722fa39 | ||
|
|
5a6870fce1 | ||
|
|
de28425993 | ||
|
|
f3eb0d2dba | ||
|
|
0630e05d50 | ||
|
|
f868507670 | ||
|
|
04032078c1 | ||
|
|
8af2714a04 | ||
|
|
c4b9cc4143 | ||
|
|
8c132d8342 | ||
|
|
8e63fafc62 | ||
|
|
63ebe16fd3 | ||
|
|
775fdd1ccb | ||
|
|
784577d86f | ||
|
|
07d27e66d1 | ||
|
|
b404316dfd | ||
|
|
edf97a13cd | ||
|
|
c384bc2e7a | ||
|
|
f16034d0cc | ||
|
|
93469d33e5 | ||
|
|
329b118810 | ||
|
|
748054de56 | ||
|
|
721b179521 | ||
|
|
d3151f978d | ||
|
|
3a25af6496 | ||
|
|
6f1512f200 | ||
|
|
d555b23275 | ||
|
|
375c42dff5 | ||
|
|
21225e7753 | ||
|
|
759ced7268 | ||
|
|
5920419e6b | ||
|
|
7c00383b62 | ||
|
|
4361641857 | ||
|
|
ac8f40353e | ||
|
|
d648c83e4c | ||
|
|
5cd1775e1d | ||
|
|
15d4676f98 | ||
|
|
dfd388ddeb | ||
|
|
8af010e5b7 | ||
|
|
4f4ea01bc0 | ||
|
|
9e4cbcb372 | ||
|
|
07d7264bc6 | ||
|
|
f3b3eba8b3 | ||
|
|
a75dbb5d62 | ||
|
|
254f46d991 | ||
|
|
59d15b0411 | ||
|
|
f0778df911 | ||
|
|
eb4d85c83d | ||
|
|
0fe00fed27 | ||
|
|
7b9d095f4e | ||
|
|
94aec6f511 | ||
|
|
ed25a8b073 | ||
|
|
c9eb936d45 | ||
|
|
7237ece1ca | ||
|
|
18485f5d95 | ||
|
|
909ce5b27d | ||
|
|
c7b82fdc97 | ||
|
|
da380ed75e | ||
|
|
687c7e3ccf | ||
|
|
484b7141d9 | ||
|
|
f60031d67b | ||
|
|
dd29063a84 | ||
|
|
f37dfbd21a | ||
|
|
bb8ef00d49 | ||
|
|
d13c654596 | ||
|
|
2cc73baa99 | ||
|
|
f740d46d47 | ||
|
|
412a5adf8f | ||
|
|
e4da2e5e03 | ||
|
|
9d7038b127 | ||
|
|
ce5af572cc | ||
|
|
6d293e544e | ||
|
|
28a8032adf | ||
|
|
d765a89139 | ||
|
|
3df5b1d075 | ||
|
|
857791445f | ||
|
|
52b28997a2 | ||
|
|
f65a6aa11f | ||
|
|
9faca5ea24 | ||
|
|
867512eee5 | ||
|
|
1436b65347 | ||
|
|
cc06588991 | ||
|
|
32bd9fa265 | ||
|
|
bdc9b155f9 | ||
|
|
1af2941594 | ||
|
|
11dc1e6f70 | ||
|
|
e08243e3b2 | ||
|
|
3a4e30f2ec | ||
|
|
ea2fa741f5 | ||
|
|
20d1bb9d32 | ||
|
|
ad48d592e7 | ||
|
|
4861aca640 | ||
|
|
82450c8250 | ||
|
|
b21b69b2b8 | ||
|
|
80ed6e76cd | ||
|
|
bb211be436 | ||
|
|
3b70ef8c84 | ||
|
|
9d57380c9a | ||
|
|
8b468c31a5 | ||
|
|
9aec608601 | ||
|
|
e542bb606d | ||
|
|
fe1b4ec9d0 | ||
|
|
f04df7a6ee | ||
|
|
1640ddd497 | ||
|
|
27148324a6 |
@@ -1,5 +1,6 @@
|
|||||||
doc/
|
doc/
|
||||||
env/
|
env/
|
||||||
|
node_modules/
|
||||||
res/
|
res/
|
||||||
local/
|
local/
|
||||||
.git/
|
.git/
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[*.{js,jsx,ts,tsx,vue}]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
@@ -46,4 +46,7 @@ jobs:
|
|||||||
- name: Run build
|
- name: Run build
|
||||||
run: python -m build
|
run: python -m build
|
||||||
- name: Check files
|
- name: Check files
|
||||||
run: unzip -l dist/pretix*whl | grep node_modules || exit 1
|
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
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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
|
||||||
@@ -72,7 +72,7 @@ jobs:
|
|||||||
run: make all compress
|
run: make all compress
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --maxfail=100
|
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
|
||||||
- name: Run concurrency tests
|
- name: Run concurrency tests
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reuse-db
|
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reuse-db
|
||||||
@@ -84,3 +84,46 @@ jobs:
|
|||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
fail_ci_if_error: false
|
fail_ci_if_error: false
|
||||||
if: matrix.database == 'postgres' && matrix.python-version == '3.13'
|
if: matrix.database == 'postgres' && matrix.python-version == '3.13'
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
name: E2E Tests
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
env:
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: pretix
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U postgres -d pretix"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pip
|
||||||
|
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pip-
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt update && sudo apt install -y gettext
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: pip3 install uv && uv pip install --system -e ".[dev]" psycopg2-binary
|
||||||
|
- name: Install JS dependencies
|
||||||
|
working-directory: ./src
|
||||||
|
run: make npminstall
|
||||||
|
- name: Compile
|
||||||
|
working-directory: ./src
|
||||||
|
run: make all compress
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: playwright install
|
||||||
|
- name: Run E2E tests
|
||||||
|
working-directory: ./src
|
||||||
|
run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10
|
||||||
|
|||||||
@@ -24,5 +24,7 @@ local/
|
|||||||
.project
|
.project
|
||||||
.pydevproject
|
.pydevproject
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -10,9 +10,9 @@ tests:
|
|||||||
- cd src
|
- cd src
|
||||||
- python manage.py check
|
- python manage.py check
|
||||||
- make all compress
|
- make all compress
|
||||||
- PRETIX_CONFIG_FILE=tests/ci_sqlite.cfg py.test -n 3 tests --maxfail=100
|
- PRETIX_CONFIG_FILE=tests/ci_sqlite.cfg py.test -n 3 tests --ignore=tests/e2e --maxfail=100
|
||||||
except:
|
except:
|
||||||
- pypi
|
- '/^v.*$/'
|
||||||
pypi:
|
pypi:
|
||||||
stage: release
|
stage: release
|
||||||
image:
|
image:
|
||||||
@@ -35,7 +35,7 @@ pypi:
|
|||||||
- twine check dist/*
|
- twine check dist/*
|
||||||
- twine upload dist/*
|
- twine upload dist/*
|
||||||
only:
|
only:
|
||||||
- pypi
|
- '/^v.*$/'
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- src/dist/
|
- src/dist/
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
17
|
24
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/*
|
||||||
+8
-5
@@ -1,6 +1,7 @@
|
|||||||
FROM python:3.13-trixie
|
FROM python:3.13-trixie
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||||
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
gettext \
|
gettext \
|
||||||
@@ -21,8 +22,7 @@ RUN apt-get update && \
|
|||||||
libmaxminddb0 \
|
libmaxminddb0 \
|
||||||
libmaxminddb-dev \
|
libmaxminddb-dev \
|
||||||
zlib1g-dev \
|
zlib1g-dev \
|
||||||
nodejs \
|
nodejs && \
|
||||||
npm && \
|
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
dpkg-reconfigure locales && \
|
dpkg-reconfigure locales && \
|
||||||
@@ -50,11 +50,14 @@ COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
|
|||||||
COPY pyproject.toml /pretix/pyproject.toml
|
COPY pyproject.toml /pretix/pyproject.toml
|
||||||
COPY _build /pretix/_build
|
COPY _build /pretix/_build
|
||||||
COPY src /pretix/src
|
COPY src /pretix/src
|
||||||
|
COPY package.json /pretix/package.json
|
||||||
|
COPY package-lock.json /pretix/package-lock.json
|
||||||
|
COPY tsconfig.json /pretix/tsconfig.json
|
||||||
|
COPY vite.config.ts /pretix/vite.config.ts
|
||||||
|
|
||||||
RUN pip3 install -U \
|
RUN pip3 install -U \
|
||||||
pip \
|
pip \
|
||||||
setuptools \
|
setuptools && \
|
||||||
wheel && \
|
|
||||||
cd /pretix && \
|
cd /pretix && \
|
||||||
PRETIX_DOCKER_BUILD=TRUE pip3 install \
|
PRETIX_DOCKER_BUILD=TRUE pip3 install \
|
||||||
-e ".[memcached]" \
|
-e ".[memcached]" \
|
||||||
|
|||||||
@@ -48,3 +48,8 @@ recursive-include src Makefile
|
|||||||
recursive-exclude doc *
|
recursive-exclude doc *
|
||||||
recursive-exclude deployment *
|
recursive-exclude deployment *
|
||||||
recursive-exclude res *
|
recursive-exclude res *
|
||||||
|
|
||||||
|
include package.json
|
||||||
|
include package-lock.json
|
||||||
|
include tsconfig.json
|
||||||
|
include vite.config.ts
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ Cart position endpoints
|
|||||||
* ``attendee_email`` (optional)
|
* ``attendee_email`` (optional)
|
||||||
* ``subevent`` (optional)
|
* ``subevent`` (optional)
|
||||||
* ``expires`` (optional)
|
* ``expires`` (optional)
|
||||||
* ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
|
* ``includes_tax`` (optional, **DEPRECATED**, do not use, will be removed)
|
||||||
* ``sales_channel`` (optional)
|
* ``sales_channel`` (optional)
|
||||||
* ``voucher`` (optional, expect a voucher code)
|
* ``voucher`` (optional, expect a voucher code)
|
||||||
* ``addons`` (optional, expect a list of nested objects of cart positions)
|
* ``addons`` (optional, expect a list of nested objects of cart positions)
|
||||||
|
|||||||
@@ -46,12 +46,14 @@ Checking a ticket in
|
|||||||
this request twice with the same nonce, the second request will also succeed but will always
|
this request twice with the same nonce, the second request will also succeed but will always
|
||||||
create only one check-in object even when the previous request was successful as well. This
|
create only one check-in object even when the previous request was successful as well. This
|
||||||
allows for a certain level of idempotency and enables you to re-try after a connection failure.
|
allows for a certain level of idempotency and enables you to re-try after a connection failure.
|
||||||
|
:<json string exchange_medium_type: To perform an exchange to a reusable medium, pass the type of the new reusable medium
|
||||||
|
:<json string exchange_medium_identifier: To perform an exchange to a reusable media, pass the identifier of the new medium
|
||||||
:<json boolean use_order_locale: Specifies that pretix should use the customer's language (``locale`` field from the
|
:<json boolean use_order_locale: Specifies that pretix should use the customer's language (``locale`` field from the
|
||||||
order) when building texts (currently only the ``reason_explanation`` response field).
|
order) when building texts (currently only the ``reason_explanation`` response field).
|
||||||
Defaults to ``false`` in which case the server will determine the language (currently
|
Defaults to ``false`` in which case the server will determine the language (currently
|
||||||
the event default language, might change in the future with support for the
|
the event default language, might change in the future with support for the
|
||||||
``Accept-Language`` header).
|
``Accept-Language`` header).
|
||||||
:>json string status: ``"ok"``, ``"incomplete"``, or ``"error"``
|
:>json string status: ``"ok"``, ``"incomplete"``, ``"exchange"``, or ``"error"``
|
||||||
:>json string reason: Reason code, only set on status ``"error"``, see below for possible values.
|
:>json string reason: Reason code, only set on status ``"error"``, see below for possible values.
|
||||||
:>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null.
|
:>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null.
|
||||||
:>json object position: Copy of the matching order position (if any was found). The contents are the same as the
|
:>json object position: Copy of the matching order position (if any was found). The contents are the same as the
|
||||||
@@ -67,6 +69,10 @@ Checking a ticket in
|
|||||||
:>json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found),
|
:>json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found),
|
||||||
including the attributes ``id``, ``name``, ``event``, ``subevent``, and ``include_pending``.
|
including the attributes ``id``, ``name``, ``event``, ``subevent``, and ``include_pending``.
|
||||||
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
|
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
|
||||||
|
:>json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``.
|
||||||
|
:>json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``.
|
||||||
|
:>json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response
|
||||||
|
object will not reflect the simulated changes.
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
@@ -224,6 +230,9 @@ Checking a ticket in
|
|||||||
* ``ambiguous`` - Multiple tickets match scan, rejected.
|
* ``ambiguous`` - Multiple tickets match scan, rejected.
|
||||||
* ``revoked`` - Ticket code has been revoked.
|
* ``revoked`` - Ticket code has been revoked.
|
||||||
* ``unapproved`` - Order has not yet been approved.
|
* ``unapproved`` - Order has not yet been approved.
|
||||||
|
* ``already_exchanged`` - Ticket already has been exchanged for a reusable medium that must now be used for check-in.
|
||||||
|
* ``medium_invalid`` - Reusable medium identifier given was not found or is not valid.
|
||||||
|
* ``medium_exists`` - Reusable medium identifier already exists, but expected to be new.
|
||||||
* ``error`` - Internal error.
|
* ``error`` - Internal error.
|
||||||
|
|
||||||
In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation``
|
In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation``
|
||||||
|
|||||||
@@ -602,7 +602,8 @@ Order position endpoints
|
|||||||
|
|
||||||
We no longer recommend using this API if you're building a ticket scanning application, as it has a few design
|
We no longer recommend using this API if you're building a ticket scanning application, as it has a few design
|
||||||
flaws that can lead to `security issues`_ or compatibility issues due to barcode content characters that are not
|
flaws that can lead to `security issues`_ or compatibility issues due to barcode content characters that are not
|
||||||
URL-safe. We recommend to use our new :ref:`check-in API <rest-checkin>` instead.
|
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.
|
||||||
|
|
||||||
:query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never
|
:query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never
|
||||||
as an ``id``. This should be always set if you are passing through untrusted, scanned
|
as an ``id``. This should be always set if you are passing through untrusted, scanned
|
||||||
@@ -741,6 +742,9 @@ Order position endpoints
|
|||||||
* ``ambiguous`` - Multiple tickets match scan, rejected.
|
* ``ambiguous`` - Multiple tickets match scan, rejected.
|
||||||
* ``revoked`` - Ticket code has been revoked.
|
* ``revoked`` - Ticket code has been revoked.
|
||||||
* ``unapproved`` - Order has not yet been approved.
|
* ``unapproved`` - Order has not yet been approved.
|
||||||
|
* ``already_exchanged`` - Ticket already has been exchanged for a reusable medium that must now be used for check-in.
|
||||||
|
* ``medium_invalid`` - Reusable medium identifier given was not found and could not be automatically created.
|
||||||
|
* ``medium_exists`` - Reusable medium identifier already exists, but expected to be new.
|
||||||
|
|
||||||
In case of reason ``rules`` or ``invalid_time``, there might be an additional response field ``reason_explanation``
|
In case of reason ``rules`` or ``invalid_time``, there might be an additional response field ``reason_explanation``
|
||||||
with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.
|
with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.
|
||||||
|
|||||||
@@ -844,3 +844,187 @@ You can also fetch existing leads (if you are authorized to do so):
|
|||||||
:statuscode 200: No error
|
:statuscode 200: No error
|
||||||
:statuscode 401: Invalid authentication code
|
:statuscode 401: Invalid authentication code
|
||||||
:statuscode 403: Not permitted to access bulk data
|
:statuscode 403: Not permitted to access bulk data
|
||||||
|
|
||||||
|
Retrieving Vouchers
|
||||||
|
"""""""""""""""""""
|
||||||
|
|
||||||
|
Vouchers returned by the App API use a different format than described in :ref:`rest-vouchers`.
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
Field Type Description
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
id integer Internal ID of the voucher
|
||||||
|
code string The voucher code that is required to redeem the voucher
|
||||||
|
max_usages integer The maximum number of times this voucher can be
|
||||||
|
redeemed (default: 1).
|
||||||
|
redeemed integer The number of times this voucher already has been
|
||||||
|
redeemed.
|
||||||
|
valid_until datetime The voucher expiration date (or ``null``).
|
||||||
|
subevent string Name of the date inside an event series this voucher belongs to (or ``null``).
|
||||||
|
tag string A string that is used for grouping vouchers
|
||||||
|
comment string An internal exhibitor comment on the voucher.
|
||||||
|
items list of strings A list of items this voucher is restricted to (or ``null``).
|
||||||
|
price_mode string Determines how this voucher affects product prices.
|
||||||
|
Possible values:
|
||||||
|
|
||||||
|
* ``none`` – No effect on price
|
||||||
|
* ``set`` – The product price is set to the given ``value``
|
||||||
|
* ``subtract`` – The product price is determined by the original price *minus* the given ``value``
|
||||||
|
* ``percent`` – The product price is determined by the original price reduced by the percentage given in ``value``
|
||||||
|
value decimal (string) The value (see ``price_mode``)
|
||||||
|
redemptions list of objects A list of objects, where each object represents an order position that has been purchased using the voucher.
|
||||||
|
Each entry will contains the fields ``attendee_fields``, ``redemption_date`` and ``subevent``.
|
||||||
|
|
||||||
|
The attendee data in the ``attendee_fields`` that is shown is based on the event's configuration, and each entry
|
||||||
|
contains the fields ``id``, ``label``, ``value``, and ``details``. ``details`` is usually empty
|
||||||
|
except in a few cases where it contains an additional list of objects
|
||||||
|
with ``value`` and ``label`` keys (e.g. splitting of names).
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
.. http:get:: /exhibitors/api/v1/vouchers/
|
||||||
|
|
||||||
|
Returns a list of all vouchers connected to the exhibitor.
|
||||||
|
|
||||||
|
Note that the ``attendee_fields`` array can contain any number of dynamic keys!
|
||||||
|
Depending on the exhibitors permission and event configuration this might be empty, or contain lots of details.
|
||||||
|
The app should dynamically show these values (read-only) with the labels sent by the server.
|
||||||
|
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /exhibitors/api/v1/vouchers/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"code": "43K6LKM37FBVR2YG",
|
||||||
|
"max_usages": 1,
|
||||||
|
"redeemed": 0,
|
||||||
|
"valid_until": null,
|
||||||
|
"subevent": null,
|
||||||
|
"tag": "testvoucher",
|
||||||
|
"comment": "",
|
||||||
|
"items": [
|
||||||
|
"All"
|
||||||
|
],
|
||||||
|
"price_mode": "set",
|
||||||
|
"value": "12.00",
|
||||||
|
"redemptions": [
|
||||||
|
{
|
||||||
|
"attendee_fields": [
|
||||||
|
{
|
||||||
|
"id": "attendee_name",
|
||||||
|
"label": "Name",
|
||||||
|
"value": "Jon Doe",
|
||||||
|
"details": [
|
||||||
|
{"label": "Given name", "value": "John"},
|
||||||
|
{"label": "Family name", "value": "Doe"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "attendee_email",
|
||||||
|
"label": "Email",
|
||||||
|
"value": "test@example.com",
|
||||||
|
"details": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"redemption_date": "2026-05-06",
|
||||||
|
"subevent": null
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:statuscode 200: No error
|
||||||
|
:statuscode 401: Invalid authentication code
|
||||||
|
:statuscode 403: Not permitted to access bulk data
|
||||||
|
|
||||||
|
.. http:get:: /exhibitors/api/v1/vouchers/(id)/
|
||||||
|
|
||||||
|
Returns the details of a single, specific voucher connected to the exhibitor.
|
||||||
|
|
||||||
|
Note that the ``attendee_fields`` array can contain any number of dynamic keys!
|
||||||
|
Depending on the exhibitors permission and event configuration this might be empty, or contain lots of details.
|
||||||
|
The app should dynamically show these values (read-only) with the labels sent by the server.
|
||||||
|
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /exhibitors/api/v1/vouchers/1/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"code": "43K6LKM37FBVR2YG",
|
||||||
|
"max_usages": 1,
|
||||||
|
"redeemed": 0,
|
||||||
|
"valid_until": null,
|
||||||
|
"subevent": null,
|
||||||
|
"tag": "testvoucher",
|
||||||
|
"comment": "",
|
||||||
|
"items": [
|
||||||
|
"All"
|
||||||
|
],
|
||||||
|
"price_mode": "set",
|
||||||
|
"value": "12.00",
|
||||||
|
"redemptions": [
|
||||||
|
{
|
||||||
|
"attendee_fields": [
|
||||||
|
{
|
||||||
|
"id": "attendee_name",
|
||||||
|
"label": "Name",
|
||||||
|
"value": "Jon Doe",
|
||||||
|
"details": [
|
||||||
|
{"label": "Given name", "value": "John"},
|
||||||
|
{"label": "Family name", "value": "Doe"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "attendee_email",
|
||||||
|
"label": "Email",
|
||||||
|
"value": "test@example.com",
|
||||||
|
"details": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"redemption_date": "2026-05-06",
|
||||||
|
"subevent": null
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:param id: The ``id`` field of the voucher to fetch
|
||||||
|
:statuscode 200: No error
|
||||||
|
:statuscode 401: Invalid authentication code
|
||||||
|
:statuscode 403: Not permitted to access bulk data
|
||||||
|
:statuscode 404: Voucher not found in system
|
||||||
@@ -131,7 +131,7 @@ allow_waitinglist boolean If ``false``,
|
|||||||
product when it is sold out.
|
product when it is sold out.
|
||||||
issue_giftcard boolean If ``true``, buying this product will yield a gift card.
|
issue_giftcard boolean If ``true``, buying this product will yield a gift card.
|
||||||
media_policy string Policy on how to handle reusable media (experimental feature).
|
media_policy string Policy on how to handle reusable media (experimental feature).
|
||||||
Possible values are ``null``, ``"new"``, ``"reuse"``, and ``"reuse_or_new"``.
|
Possible values are ``null``, ``"new"``, ``"reuse"``, ``"reuse_or_new"``, ``"append"``, and ``"append_or_new"``.
|
||||||
media_type string Type of reusable media to work on (experimental feature). See :ref:`rest-reusablemedia` for possible choices.
|
media_type string Type of reusable media to work on (experimental feature). See :ref:`rest-reusablemedia` for possible choices.
|
||||||
show_quota_left boolean Publicly show how many tickets are still available.
|
show_quota_left boolean Publicly show how many tickets are still available.
|
||||||
If this is ``null``, the event default is used.
|
If this is ``null``, the event default is used.
|
||||||
|
|||||||
@@ -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_from`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
|
||||||
* ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
|
* ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
|
||||||
* ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected)
|
* ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected)
|
||||||
* ``use_reusable_medium`` (optional, causes the new ticket to take over the given reusable medium, identified by its ID)
|
* ``use_reusable_medium`` (optional, causes the new ticket to be connected to the given reusable medium, identified by its ID)
|
||||||
* ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run)
|
* ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run)
|
||||||
* ``answers``
|
* ``answers``
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ id integer Internal ID of
|
|||||||
type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``.
|
type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``.
|
||||||
organizer string Organizer slug of the organizer who "owns" this medium.
|
organizer string Organizer slug of the organizer who "owns" this medium.
|
||||||
identifier string Unique identifier of the medium. The format depends on the ``type``.
|
identifier string Unique identifier of the medium. The format depends on the ``type``.
|
||||||
|
claim_token string Secret token to claim ownership of the medium (or ``null``)
|
||||||
|
label string Label to identify the medium, usually something human readable (or ``null``)
|
||||||
active boolean Whether this medium may be used.
|
active boolean Whether this medium may be used.
|
||||||
created datetime Date of creation
|
created datetime Date of creation
|
||||||
updated datetime Date of last modification
|
updated datetime Date of last modification
|
||||||
expires datetime Expiry date (or ``null``)
|
expires datetime Expiry date (or ``null``)
|
||||||
customer string Identifier of a customer account this medium belongs to.
|
customer string Identifier of a customer account this medium belongs to.
|
||||||
linked_orderposition integer Internal ID of a ticket this medium is linked 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_giftcard integer Internal ID of a gift card this medium is linked to.
|
linked_giftcard integer Internal ID of a gift card this medium is linked to.
|
||||||
info object Additional data, content depends on the ``type``. Consider
|
info object Additional data, content depends on the ``type``. Consider
|
||||||
this internal to the system and don't use it for your own data.
|
this internal to the system and don't use it for your own data.
|
||||||
@@ -39,6 +43,14 @@ Existing media types are:
|
|||||||
- ``nfc_uid``
|
- ``nfc_uid``
|
||||||
- ``nfc_mf0aes``
|
- ``nfc_mf0aes``
|
||||||
|
|
||||||
|
|
||||||
|
.. versionchanged:: 2026.5
|
||||||
|
|
||||||
|
The ``claim_token``, ``label``, ``linked_orderpositions`` attributes have been added, the ``linked_orderposition`` attribute has been
|
||||||
|
deprecated. Note: To maintain backwards compatibility ``linked_orderposition`` contains the internal ID of the linked order position
|
||||||
|
if the medium has exactly one order position in ``linked_orderpositions``.
|
||||||
|
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
|
|
||||||
@@ -77,6 +89,7 @@ Endpoints
|
|||||||
"active": True,
|
"active": True,
|
||||||
"expires": None,
|
"expires": None,
|
||||||
"customer": None,
|
"customer": None,
|
||||||
|
"linked_orderpositions": [],
|
||||||
"linked_orderposition": None,
|
"linked_orderposition": None,
|
||||||
"linked_giftcard": None,
|
"linked_giftcard": None,
|
||||||
"notes": None,
|
"notes": None,
|
||||||
@@ -92,10 +105,13 @@ Endpoints
|
|||||||
:query string customer: Only show media linked to the given customer.
|
:query string customer: Only show media linked to the given customer.
|
||||||
:query string created_since: Only show media created since a given date.
|
:query string created_since: Only show media created since a given date.
|
||||||
:query string updated_since: Only show media updated since a given date.
|
:query string updated_since: Only show media updated since a given date.
|
||||||
|
:query integer linked_orderpositions: Only show media linked to the given tickets. Note: you can pass multiple ticket IDs by passing
|
||||||
|
``linked_orderpositions`` multiple times. Any medium matching any linked orderposition will be returned.
|
||||||
:query integer linked_orderposition: Only show media linked to the given ticket.
|
:query integer linked_orderposition: Only show media linked to the given ticket.
|
||||||
:query integer linked_giftcard: Only show media linked to the given gift card.
|
:query integer linked_giftcard: Only show media linked to the given gift card.
|
||||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``,
|
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderpositions"``,
|
||||||
or ``"customer"``, the respective field will be shown as a nested value instead of just an ID.
|
``"linked_orderposition"`` (**DEPRECATED**), 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
|
The nested objects are identical to the respective resources, except that order positions
|
||||||
will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make
|
will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make
|
||||||
matching easier. The parameter can be given multiple times.
|
matching easier. The parameter can be given multiple times.
|
||||||
@@ -134,6 +150,7 @@ Endpoints
|
|||||||
"active": True,
|
"active": True,
|
||||||
"expires": None,
|
"expires": None,
|
||||||
"customer": None,
|
"customer": None,
|
||||||
|
"linked_orderpositions": [],
|
||||||
"linked_orderposition": None,
|
"linked_orderposition": None,
|
||||||
"linked_giftcard": None,
|
"linked_giftcard": None,
|
||||||
"notes": None,
|
"notes": None,
|
||||||
@@ -191,6 +208,7 @@ Endpoints
|
|||||||
"active": True,
|
"active": True,
|
||||||
"expires": None,
|
"expires": None,
|
||||||
"customer": None,
|
"customer": None,
|
||||||
|
"linked_orderpositions": [],
|
||||||
"linked_orderposition": None,
|
"linked_orderposition": None,
|
||||||
"linked_giftcard": None,
|
"linked_giftcard": None,
|
||||||
"notes": None,
|
"notes": None,
|
||||||
@@ -198,9 +216,9 @@ Endpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer to look up a medium for
|
:param organizer: The ``slug`` field of the organizer to look up a medium for
|
||||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
|
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective
|
||||||
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
||||||
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
the respective resources, except that the ``linked_orderpositions`` each will have an attribute of the
|
||||||
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
||||||
can be given multiple times.
|
can be given multiple times.
|
||||||
:statuscode 201: no error
|
:statuscode 201: no error
|
||||||
@@ -227,6 +245,7 @@ Endpoints
|
|||||||
"active": True,
|
"active": True,
|
||||||
"expires": None,
|
"expires": None,
|
||||||
"customer": None,
|
"customer": None,
|
||||||
|
"linked_orderpositions": [],
|
||||||
"linked_orderposition": None,
|
"linked_orderposition": None,
|
||||||
"linked_giftcard": None,
|
"linked_giftcard": None,
|
||||||
"notes": None,
|
"notes": None,
|
||||||
@@ -251,6 +270,7 @@ Endpoints
|
|||||||
"active": True,
|
"active": True,
|
||||||
"expires": None,
|
"expires": None,
|
||||||
"customer": None,
|
"customer": None,
|
||||||
|
"linked_orderpositions": [],
|
||||||
"linked_orderposition": None,
|
"linked_orderposition": None,
|
||||||
"linked_giftcard": None,
|
"linked_giftcard": None,
|
||||||
"notes": None,
|
"notes": None,
|
||||||
@@ -258,7 +278,7 @@ Endpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer to create a medium for
|
:param organizer: The ``slug`` field of the organizer to create a medium for
|
||||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
|
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective
|
||||||
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
||||||
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
||||||
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
||||||
@@ -287,7 +307,7 @@ Endpoints
|
|||||||
Content-Length: 94
|
Content-Length: 94
|
||||||
|
|
||||||
{
|
{
|
||||||
"linked_orderposition": 13
|
"linked_orderpositions": [13, 29]
|
||||||
}
|
}
|
||||||
|
|
||||||
**Example response**:
|
**Example response**:
|
||||||
@@ -308,7 +328,8 @@ Endpoints
|
|||||||
"active": True,
|
"active": True,
|
||||||
"expires": None,
|
"expires": None,
|
||||||
"customer": None,
|
"customer": None,
|
||||||
"linked_orderposition": 13,
|
"linked_orderpositions": [13, 29],
|
||||||
|
"linked_orderposition": None,
|
||||||
"linked_giftcard": None,
|
"linked_giftcard": None,
|
||||||
"notes": None,
|
"notes": None,
|
||||||
"info": {}
|
"info": {}
|
||||||
@@ -316,7 +337,7 @@ Endpoints
|
|||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer to modify
|
:param organizer: The ``slug`` field of the organizer to modify
|
||||||
:param id: The ``id`` field of the medium to modify
|
:param id: The ``id`` field of the medium to modify
|
||||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
|
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective
|
||||||
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
||||||
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
||||||
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ The following values for ``action_types`` are valid with pretix core:
|
|||||||
* ``pretix.subevent.changed``
|
* ``pretix.subevent.changed``
|
||||||
* ``pretix.subevent.deleted``
|
* ``pretix.subevent.deleted``
|
||||||
* ``pretix.event.item.*``
|
* ``pretix.event.item.*``
|
||||||
|
* ``pretix.event.quota.*``
|
||||||
* ``pretix.event.live.activated``
|
* ``pretix.event.live.activated``
|
||||||
* ``pretix.event.live.deactivated``
|
* ``pretix.event.live.deactivated``
|
||||||
* ``pretix.event.testmode.activated``
|
* ``pretix.event.testmode.activated``
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ Backend
|
|||||||
|
|
||||||
.. automodule:: pretix.control.signals
|
.. automodule:: pretix.control.signals
|
||||||
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
|
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
|
||||||
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms,
|
order_info, order_approve_info, event_settings_widget, oauth_application_registered,
|
||||||
item_formsets, order_search_filter_q, order_search_forms
|
order_position_buttons, subevent_forms, item_formsets, order_search_filter_q, order_search_forms, subevent_detail_html
|
||||||
|
|
||||||
.. automodule:: pretix.base.signals
|
.. automodule:: pretix.base.signals
|
||||||
:no-index:
|
:no-index:
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ is a python method that emulates a behavior similar to ``reverse``:
|
|||||||
|
|
||||||
If you need to communicate the URL externally, you can use a different method to ensure that it is always an absolute URL:
|
If you need to communicate the URL externally, you can use a different method to ensure that it is always an absolute URL:
|
||||||
|
|
||||||
.. autofunction:: pretix.multidomain.urlreverse.build_absolute_uri
|
.. autofunction:: pretix.multidomain.urlreverse.eventreverse_absolute
|
||||||
|
|
||||||
In addition, there is a template tag that works similar to ``url`` but takes an event or organizer object
|
In addition, there is a template tag that works similar to ``url`` but takes an event or organizer object
|
||||||
as its first argument and can be used like this::
|
as its first argument and can be used like this::
|
||||||
|
|||||||
@@ -110,6 +110,56 @@ process::
|
|||||||
|
|
||||||
However, beware that code changes will not auto-reload within Celery.
|
However, beware that code changes will not auto-reload within Celery.
|
||||||
|
|
||||||
|
Running the local development server will also automatically start a vite dev server for all control vue components.
|
||||||
|
|
||||||
|
Run the widget development server
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
To locally develop the presale widget you need to start a separate vite dev server using::
|
||||||
|
|
||||||
|
npm run dev:widget
|
||||||
|
|
||||||
|
You can control the org, event and much more via query parameters like this::
|
||||||
|
|
||||||
|
http://localhost:5180/?org=testorg&event=testevent
|
||||||
|
|
||||||
|
The following query parameters are supported:
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:header-rows: 1
|
||||||
|
:widths: 20 20 60
|
||||||
|
|
||||||
|
* - Parameter
|
||||||
|
- Default
|
||||||
|
- Description
|
||||||
|
* - ``org``
|
||||||
|
- ``testorg``
|
||||||
|
- Organization slug
|
||||||
|
* - ``event``
|
||||||
|
- ``testevent``
|
||||||
|
- Event slug
|
||||||
|
* - ``host``
|
||||||
|
- ``http://localhost:8000``
|
||||||
|
- Backend host URL
|
||||||
|
* - ``type``
|
||||||
|
- ``widget``
|
||||||
|
- Element type: ``widget`` or ``button``
|
||||||
|
* - ``mode``
|
||||||
|
- ``dev``
|
||||||
|
- ``dev`` loads the Vite dev source, ``prod`` loads the built ``v2.{lang}.js``
|
||||||
|
* - ``lang``
|
||||||
|
- ``de``
|
||||||
|
- Language code for the prod script
|
||||||
|
* - ``button-text``
|
||||||
|
- ``Buy tickets!``
|
||||||
|
- Text content for the button (only used when ``type=button``)
|
||||||
|
|
||||||
|
Any other query parameter is passed through as an attribute on the widget/button element.
|
||||||
|
For example, ``?skip-ssl-check&list-type=calendar&items=123`` adds those attributes directly.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.. _`checksandtests`:
|
.. _`checksandtests`:
|
||||||
|
|
||||||
Code checks and unit tests
|
Code checks and unit tests
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-21
@@ -27,14 +27,14 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
|
"arabic-reshaper==3.0.1", # Support for Arabic in reportlab
|
||||||
"babel",
|
"babel",
|
||||||
"BeautifulSoup4==4.14.*",
|
"BeautifulSoup4==4.15.*",
|
||||||
"bleach==6.3.*",
|
"bleach==6.4.*",
|
||||||
"celery==5.6.*",
|
"celery==5.6.*",
|
||||||
"chardet==5.2.*",
|
"chardet==5.2.*",
|
||||||
"cryptography>=47.0.0",
|
"cryptography>=49.0.0",
|
||||||
"css-inline==0.20.*",
|
"css-inline==0.21.*",
|
||||||
"defusedcsv>=3.0.0",
|
"defusedcsv>=3.0.0",
|
||||||
"dnspython==2.*",
|
"dnspython==2.*",
|
||||||
"Django[argon2]==5.2.*",
|
"Django[argon2]==5.2.*",
|
||||||
@@ -43,7 +43,7 @@ dependencies = [
|
|||||||
"django-countries==8.2.*",
|
"django-countries==8.2.*",
|
||||||
"django-filter==25.1",
|
"django-filter==25.1",
|
||||||
"django-formset-js-improved==0.5.0.5",
|
"django-formset-js-improved==0.5.0.5",
|
||||||
"django-formtools==2.5.1",
|
"django-formtools==2.6.1",
|
||||||
"django-hierarkey==2.0.*,>=2.0.1",
|
"django-hierarkey==2.0.*,>=2.0.1",
|
||||||
"django-hijack==3.7.*",
|
"django-hijack==3.7.*",
|
||||||
"django-i18nfield==1.11.*",
|
"django-i18nfield==1.11.*",
|
||||||
@@ -53,10 +53,11 @@ dependencies = [
|
|||||||
"django-oauth-toolkit==2.3.*",
|
"django-oauth-toolkit==2.3.*",
|
||||||
"django-otp==1.7.*",
|
"django-otp==1.7.*",
|
||||||
"django-phonenumber-field==8.4.*",
|
"django-phonenumber-field==8.4.*",
|
||||||
"django-redis==6.0.*",
|
"django-querytagger==0.0.3",
|
||||||
|
"django-redis==7.0.*",
|
||||||
"django-scopes==2.0.*",
|
"django-scopes==2.0.*",
|
||||||
"django-statici18n==2.7.*",
|
"django-statici18n==2.7.*",
|
||||||
"djangorestframework==3.16.*",
|
"djangorestframework==3.17.*",
|
||||||
"dnspython==2.8.*",
|
"dnspython==2.8.*",
|
||||||
"drf_ujson2==1.7.*",
|
"drf_ujson2==1.7.*",
|
||||||
"geoip2==5.*",
|
"geoip2==5.*",
|
||||||
@@ -74,11 +75,11 @@ dependencies = [
|
|||||||
"packaging",
|
"packaging",
|
||||||
"paypalrestsdk==1.13.*",
|
"paypalrestsdk==1.13.*",
|
||||||
"paypal-checkout-serversdk==1.0.*",
|
"paypal-checkout-serversdk==1.0.*",
|
||||||
"PyJWT==2.12.*",
|
"PyJWT==2.13.*",
|
||||||
"phonenumberslite==9.0.*",
|
"phonenumberslite==9.0.*",
|
||||||
"Pillow==12.2.*",
|
"Pillow==12.3.*",
|
||||||
"pretix-plugin-build",
|
"pretix-plugin-build",
|
||||||
"protobuf==7.34.*",
|
"protobuf==7.35.*",
|
||||||
"psycopg2-binary",
|
"psycopg2-binary",
|
||||||
"pycountry",
|
"pycountry",
|
||||||
"pycparser==3.0",
|
"pycparser==3.0",
|
||||||
@@ -91,9 +92,9 @@ dependencies = [
|
|||||||
"pyuca",
|
"pyuca",
|
||||||
"qrcode==8.2",
|
"qrcode==8.2",
|
||||||
"redis==7.4.*",
|
"redis==7.4.*",
|
||||||
"reportlab==4.4.*",
|
"reportlab==5.0.*",
|
||||||
"requests==2.32.*",
|
"requests==2.34.*",
|
||||||
"sentry-sdk==2.58.*",
|
"sentry-sdk==2.64.*",
|
||||||
"sepaxml==2.7.*",
|
"sepaxml==2.7.*",
|
||||||
"stripe==7.9.*",
|
"stripe==7.9.*",
|
||||||
"text-unidecode==1.*",
|
"text-unidecode==1.*",
|
||||||
@@ -101,30 +102,32 @@ dependencies = [
|
|||||||
"tqdm==4.*",
|
"tqdm==4.*",
|
||||||
"ua-parser==1.0.*",
|
"ua-parser==1.0.*",
|
||||||
"vobject==0.9.*",
|
"vobject==0.9.*",
|
||||||
"webauthn==2.7.*",
|
"webauthn==3.0.*",
|
||||||
"zeep==4.3.*"
|
"zeep==4.3.*"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
memcached = ["pylibmc"]
|
memcached = ["pylibmc"]
|
||||||
dev = [
|
dev = [
|
||||||
"aiohttp==3.13.*",
|
"aiohttp==3.14.*",
|
||||||
"coverage",
|
"coverage",
|
||||||
"coveralls",
|
"coveralls",
|
||||||
"fakeredis==2.34.*",
|
"fakeredis==2.36.*",
|
||||||
"flake8==7.3.*",
|
"flake8==7.3.*",
|
||||||
"freezegun",
|
"freezegun",
|
||||||
"isort==8.0.*",
|
"isort==8.0.*",
|
||||||
"pep8-naming==0.15.*",
|
"pep8-naming==0.15.*",
|
||||||
"potypo",
|
"potypo",
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.4.0",
|
||||||
"pytest-cache",
|
"pytest-cache",
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
"pytest-django==4.*",
|
"pytest-django==4.*",
|
||||||
"pytest-mock==3.15.*",
|
"pytest-mock==3.15.*",
|
||||||
"pytest-sugar",
|
"pytest-sugar",
|
||||||
"pytest-xdist==3.8.*",
|
"pytest-xdist==3.8.*",
|
||||||
"pytest==9.0.*",
|
"pytest-playwright",
|
||||||
|
"pytest==9.1.*",
|
||||||
|
"playwright",
|
||||||
"responses",
|
"responses",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -137,8 +140,6 @@ build-backend = "backend"
|
|||||||
backend-path = ["_build"]
|
backend-path = ["_build"]
|
||||||
requires = [
|
requires = [
|
||||||
"setuptools",
|
"setuptools",
|
||||||
"setuptools-rust",
|
|
||||||
"wheel",
|
|
||||||
"importlib_metadata",
|
"importlib_metadata",
|
||||||
"tomli",
|
"tomli",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -37,4 +37,9 @@ ignore =
|
|||||||
CONTRIBUTING.md
|
CONTRIBUTING.md
|
||||||
Dockerfile
|
Dockerfile
|
||||||
SECURITY.md
|
SECURITY.md
|
||||||
|
eslint.config.mjs
|
||||||
|
package-lock.json
|
||||||
|
package.json
|
||||||
|
tsconfig.json
|
||||||
|
vite.config.js
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -6,13 +6,13 @@ localecompile:
|
|||||||
./manage.py compilemessages
|
./manage.py compilemessages
|
||||||
|
|
||||||
localegen:
|
localegen:
|
||||||
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
|
./manage.py makemessages --keep-pot --add-location file --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)
|
./manage.py makemessages --keep-pot --add-location file -e js,ts,vue -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
|
||||||
|
|
||||||
staticfiles: jsi18n
|
staticfiles: npminstall npmbuild jsi18n
|
||||||
./manage.py collectstatic --noinput
|
./manage.py collectstatic --noinput
|
||||||
|
|
||||||
compress: npminstall
|
compress:
|
||||||
./manage.py compress
|
./manage.py compress
|
||||||
|
|
||||||
jsi18n: localecompile
|
jsi18n: localecompile
|
||||||
@@ -25,8 +25,8 @@ coverage:
|
|||||||
coverage run -m py.test
|
coverage run -m py.test
|
||||||
|
|
||||||
npminstall:
|
npminstall:
|
||||||
# keep this in sync with pretix/_build.py!
|
npm ci
|
||||||
mkdir -p pretix/static.dist/node_prefix/
|
|
||||||
cp -r pretix/static/npm_dir/* pretix/static.dist/node_prefix/
|
npmbuild:
|
||||||
npm ci --prefix=pretix/static.dist/node_prefix
|
npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -19,4 +19,4 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
__version__ = "2026.5.0.dev0"
|
__version__ = "2026.7.0.dev0"
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
|
||||||
'django.contrib.humanize',
|
'django.contrib.humanize',
|
||||||
|
# pretix needs to go before staticfiles
|
||||||
|
# so we can override the runserver command
|
||||||
'pretix.base',
|
'pretix.base',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
'pretix.control',
|
'pretix.control',
|
||||||
'pretix.presale',
|
'pretix.presale',
|
||||||
'pretix.multidomain',
|
'pretix.multidomain',
|
||||||
@@ -116,6 +118,7 @@ ALL_LANGUAGES = [
|
|||||||
('sv', _('Swedish')),
|
('sv', _('Swedish')),
|
||||||
('es', _('Spanish')),
|
('es', _('Spanish')),
|
||||||
('es-419', _('Spanish (Latin America)')),
|
('es-419', _('Spanish (Latin America)')),
|
||||||
|
('th', _('Thai')),
|
||||||
('tr', _('Turkish')),
|
('tr', _('Turkish')),
|
||||||
('uk', _('Ukrainian')),
|
('uk', _('Ukrainian')),
|
||||||
]
|
]
|
||||||
@@ -243,7 +246,6 @@ STORAGES = {
|
|||||||
|
|
||||||
COMPRESS_PRECOMPILERS = (
|
COMPRESS_PRECOMPILERS = (
|
||||||
('text/x-scss', 'django_libsass.SassCompiler'),
|
('text/x-scss', 'django_libsass.SassCompiler'),
|
||||||
('text/vue', 'pretix.helpers.compressor.VueCompiler'),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
COMPRESS_OFFLINE_CONTEXT = {
|
COMPRESS_OFFLINE_CONTEXT = {
|
||||||
|
|||||||
@@ -21,13 +21,13 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from setuptools.command.build import build
|
from setuptools.command.build import build
|
||||||
from setuptools.command.build_ext import build_ext
|
from setuptools.command.build_ext import build_ext
|
||||||
|
|
||||||
here = os.path.abspath(os.path.dirname(__file__))
|
here = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
project_root = os.path.abspath(os.path.join(here, '..', '..'))
|
||||||
npm_installed = False
|
npm_installed = False
|
||||||
|
|
||||||
|
|
||||||
@@ -35,14 +35,14 @@ def npm_install():
|
|||||||
global npm_installed
|
global npm_installed
|
||||||
|
|
||||||
if not npm_installed:
|
if not npm_installed:
|
||||||
# keep this in sync with Makefile!
|
subprocess.check_call('npm ci', shell=True, cwd=project_root)
|
||||||
node_prefix = os.path.join(here, 'static.dist', 'node_prefix')
|
|
||||||
os.makedirs(node_prefix, exist_ok=True)
|
|
||||||
shutil.copytree(os.path.join(here, 'static', 'npm_dir'), node_prefix, dirs_exist_ok=True)
|
|
||||||
subprocess.check_call('npm ci', shell=True, cwd=node_prefix)
|
|
||||||
npm_installed = True
|
npm_installed = True
|
||||||
|
|
||||||
|
|
||||||
|
def npm_build():
|
||||||
|
subprocess.check_call('npm run build', shell=True, cwd=project_root)
|
||||||
|
|
||||||
|
|
||||||
class CustomBuild(build):
|
class CustomBuild(build):
|
||||||
def run(self):
|
def run(self):
|
||||||
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
|
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
|
||||||
@@ -62,6 +62,7 @@ class CustomBuild(build):
|
|||||||
settings.COMPRESS_OFFLINE = True
|
settings.COMPRESS_OFFLINE = True
|
||||||
|
|
||||||
npm_install()
|
npm_install()
|
||||||
|
npm_build()
|
||||||
management.call_command('compilemessages', verbosity=1)
|
management.call_command('compilemessages', verbosity=1)
|
||||||
management.call_command('compilejsi18n', verbosity=1)
|
management.call_command('compilejsi18n', verbosity=1)
|
||||||
management.call_command('collectstatic', verbosity=1, interactive=False)
|
management.call_command('collectstatic', verbosity=1, interactive=False)
|
||||||
|
|||||||
@@ -47,3 +47,5 @@ HAS_MEMCACHED = False
|
|||||||
HAS_CELERY = False
|
HAS_CELERY = False
|
||||||
HAS_GEOIP = False
|
HAS_GEOIP = False
|
||||||
SENTRY_ENABLED = False
|
SENTRY_ENABLED = False
|
||||||
|
VITE_DEV_MODE = False
|
||||||
|
VITE_IGNORE = False
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
|||||||
('POST', 'api-v1:checkinrpc.redeem'),
|
('POST', 'api-v1:checkinrpc.redeem'),
|
||||||
('GET', 'api-v1:checkinrpc.search'),
|
('GET', 'api-v1:checkinrpc.search'),
|
||||||
('GET', 'api-v1:reusablemedium-list'),
|
('GET', 'api-v1:reusablemedium-list'),
|
||||||
|
('POST', 'api-v1:reusablemedium-lookup'),
|
||||||
|
('PATCH', 'api-v1:reusablemedium-detail')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -88,11 +88,20 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
|
|||||||
nonce = serializers.CharField(required=False, allow_null=True)
|
nonce = serializers.CharField(required=False, allow_null=True)
|
||||||
datetime = serializers.DateTimeField(required=False, allow_null=True)
|
datetime = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
answers = serializers.JSONField(required=False, allow_null=True)
|
answers = serializers.JSONField(required=False, allow_null=True)
|
||||||
|
exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES)
|
||||||
|
exchange_medium_identifier = serializers.CharField(required=False)
|
||||||
|
simulate = serializers.BooleanField(default=False, required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event')
|
self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event')
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
exchange_fields = ["exchange_medium_type", "exchange_medium_identifier"]
|
||||||
|
if any(attrs.get(k) is None for k in exchange_fields) and not all(attrs.get(k) is None for k in exchange_fields):
|
||||||
|
raise ValidationError("If you set any of exchange_medium_type or exchange_medium_identifier, you need to set both of them.")
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class MiniCheckinListSerializer(I18nAwareModelSerializer):
|
class MiniCheckinListSerializer(I18nAwareModelSerializer):
|
||||||
event = serializers.SlugRelatedField(slug_field='slug', read_only=True)
|
event = serializers.SlugRelatedField(slug_field='slug', read_only=True)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ from pretix.base.settings import (
|
|||||||
LazyI18nStringList, validate_event_settings,
|
LazyI18nStringList, validate_event_settings,
|
||||||
)
|
)
|
||||||
from pretix.base.signals import api_event_settings_fields
|
from pretix.base.signals import api_event_settings_fields
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_event_url(self, event):
|
def get_event_url(self, event):
|
||||||
return build_absolute_uri(event, 'presale:event.index')
|
return eventreverse_absolute(event, 'presale:event.index')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Event
|
model = Event
|
||||||
@@ -747,6 +747,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'max_items_per_order',
|
'max_items_per_order',
|
||||||
'reservation_time',
|
'reservation_time',
|
||||||
'contact_mail',
|
'contact_mail',
|
||||||
|
'contact_url',
|
||||||
'show_variations_expanded',
|
'show_variations_expanded',
|
||||||
'hide_sold_out',
|
'hide_sold_out',
|
||||||
'meta_noindex',
|
'meta_noindex',
|
||||||
@@ -871,6 +872,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'og_image',
|
'og_image',
|
||||||
'name_scheme',
|
'name_scheme',
|
||||||
'reusable_media_active',
|
'reusable_media_active',
|
||||||
|
'reusable_media_usage_enforced',
|
||||||
'reusable_media_type_barcode',
|
'reusable_media_type_barcode',
|
||||||
'reusable_media_type_barcode_identifier_length',
|
'reusable_media_type_barcode_identifier_length',
|
||||||
'reusable_media_type_nfc_uid',
|
'reusable_media_type_nfc_uid',
|
||||||
@@ -885,6 +887,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
readonly_fields = [
|
readonly_fields = [
|
||||||
# These are read-only since they are currently only settable on organizers, not events
|
# These are read-only since they are currently only settable on organizers, not events
|
||||||
'reusable_media_active',
|
'reusable_media_active',
|
||||||
|
'reusable_media_usage_enforced',
|
||||||
'reusable_media_type_barcode',
|
'reusable_media_type_barcode',
|
||||||
'reusable_media_type_barcode_identifier_length',
|
'reusable_media_type_barcode_identifier_length',
|
||||||
'reusable_media_type_nfc_uid',
|
'reusable_media_type_nfc_uid',
|
||||||
@@ -970,6 +973,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
|||||||
'reusable_media_type_nfc_uid',
|
'reusable_media_type_nfc_uid',
|
||||||
'reusable_media_type_nfc_mf0aes',
|
'reusable_media_type_nfc_mf0aes',
|
||||||
'reusable_media_type_nfc_mf0aes_random_uid',
|
'reusable_media_type_nfc_mf0aes_random_uid',
|
||||||
|
'reusable_media_usage_enforced',
|
||||||
'system_question_order',
|
'system_question_order',
|
||||||
'tax_rule_payment',
|
'tax_rule_payment',
|
||||||
'tax_rule_cancellation',
|
'tax_rule_cancellation',
|
||||||
|
|||||||
@@ -133,37 +133,43 @@ class JobRunSerializer(serializers.Serializer):
|
|||||||
return not bool(self._errors)
|
return not bool(self._errors)
|
||||||
|
|
||||||
|
|
||||||
|
class ExportFormDataField(serializers.Field):
|
||||||
|
def get_attribute(self, instance):
|
||||||
|
return (instance.export_identifier, instance.export_form_data)
|
||||||
|
|
||||||
|
def to_representation(self, value):
|
||||||
|
export_identifier, export_form_data = value
|
||||||
|
exporter = self.context['exporters'].get(export_identifier)
|
||||||
|
if exporter:
|
||||||
|
return JobRunSerializer(exporter=exporter).to_representation(export_form_data)
|
||||||
|
else:
|
||||||
|
return export_form_data
|
||||||
|
|
||||||
|
def get_value(self, dictionary):
|
||||||
|
return dictionary
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
if "export_form_data" in data:
|
||||||
|
identifier = data.get('export_identifier', self.parent.instance.export_identifier if self.parent.instance else None)
|
||||||
|
exporter = self.context['exporters'].get(identifier)
|
||||||
|
if exporter:
|
||||||
|
return JobRunSerializer(exporter=exporter).to_internal_value(data["export_form_data"])
|
||||||
|
else:
|
||||||
|
return data['export_form_data']
|
||||||
|
|
||||||
|
|
||||||
class ScheduledExportSerializer(serializers.ModelSerializer):
|
class ScheduledExportSerializer(serializers.ModelSerializer):
|
||||||
schedule_next_run = serializers.DateTimeField(read_only=True)
|
schedule_next_run = serializers.DateTimeField(read_only=True)
|
||||||
export_identifier = serializers.ChoiceField(choices=[])
|
export_identifier = serializers.ChoiceField(choices=[])
|
||||||
locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en')
|
locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en')
|
||||||
owner = serializers.SlugRelatedField(slug_field='email', read_only=True)
|
owner = serializers.SlugRelatedField(slug_field='email', read_only=True)
|
||||||
error_counter = serializers.IntegerField(read_only=True)
|
error_counter = serializers.IntegerField(read_only=True)
|
||||||
|
export_form_data = ExportFormDataField()
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['export_identifier'].choices = [(e, e) for e in self.context['exporters']]
|
self.fields['export_identifier'].choices = [(e, e) for e in self.context['exporters']]
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
if attrs.get("export_form_data"):
|
|
||||||
identifier = attrs.get('export_identifier', self.instance.export_identifier if self.instance else None)
|
|
||||||
exporter = self.context['exporters'].get(identifier)
|
|
||||||
if exporter:
|
|
||||||
try:
|
|
||||||
attrs["export_form_data"] = JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"])
|
|
||||||
except ValidationError as e:
|
|
||||||
raise ValidationError({"export_form_data": e.detail})
|
|
||||||
else:
|
|
||||||
raise ValidationError({"export_identifier": ["Unknown exporter."]})
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
def to_representation(self, instance):
|
|
||||||
repr = super().to_representation(instance)
|
|
||||||
exporter = self.context['exporters'].get(instance.export_identifier)
|
|
||||||
if exporter:
|
|
||||||
repr["export_form_data"] = JobRunSerializer(exporter=exporter).to_representation(repr["export_form_data"])
|
|
||||||
return repr
|
|
||||||
|
|
||||||
def validate_mail_additional_recipients(self, value):
|
def validate_mail_additional_recipients(self, value):
|
||||||
d = value.replace(' ', '')
|
d = value.replace(' ', '')
|
||||||
if len(d.split(',')) > 25:
|
if len(d.split(',')) > 25:
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
|
|||||||
return value
|
return value
|
||||||
return super().to_representation(value)
|
return super().to_representation(value)
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
value = super().to_internal_value(data)
|
||||||
|
if value is not None:
|
||||||
|
return value.pk
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class FormFieldWrapperField(serializers.Field):
|
class FormFieldWrapperField(serializers.Field):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -66,13 +66,14 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
expand_nested = self.context['request'].query_params.getlist('expand')
|
||||||
|
|
||||||
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
|
if 'linked_giftcard' in expand_nested:
|
||||||
if not self.context["can_read_giftcards"]:
|
if not self.context["can_read_giftcards"]:
|
||||||
raise PermissionDenied("No permission to access gift card details.")
|
raise PermissionDenied("No permission to access gift card details.")
|
||||||
|
|
||||||
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context)
|
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context)
|
||||||
if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'):
|
if 'linked_giftcard.owner_ticket' in expand_nested:
|
||||||
self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
|
self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
|
||||||
else:
|
else:
|
||||||
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
|
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
|
||||||
@@ -81,17 +82,27 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
|||||||
queryset=self.context['organizer'].issued_gift_cards.all()
|
queryset=self.context['organizer'].issued_gift_cards.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
|
# keep linked_orderposition (singular) for backwards compatibility, will be overwritten in self.validate
|
||||||
# Permission Check performed in to_representation
|
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
|
||||||
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
|
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
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
|
self.fields['linked_orderpositions'] = serializers.PrimaryKeyRelatedField(
|
||||||
|
many=True,
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
|
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
|
||||||
)
|
)
|
||||||
|
|
||||||
if 'customer' in self.context['request'].query_params.getlist('expand'):
|
if 'customer' in expand_nested:
|
||||||
if not self.context["can_read_customers"]:
|
if not self.context["can_read_customers"]:
|
||||||
raise PermissionDenied("No permission to access customer details.")
|
raise PermissionDenied("No permission to access customer details.")
|
||||||
|
|
||||||
@@ -106,6 +117,21 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
data = super().validate(data)
|
data = super().validate(data)
|
||||||
|
if 'linked_orderposition' in data:
|
||||||
|
linked_orderposition = data['linked_orderposition']
|
||||||
|
# backwards-compatibility
|
||||||
|
if 'linked_orderpositions' in data:
|
||||||
|
raise ValidationError({
|
||||||
|
'linked_orderposition': 'You cannot use linked_orderposition and linked_orderpositions at the same time.'
|
||||||
|
})
|
||||||
|
if self.instance and self.instance.linked_orderpositions.count() > 1:
|
||||||
|
raise ValidationError({
|
||||||
|
'linked_orderposition': 'There are more than one linked_orderposition. You need to use linked_orderpositions.'
|
||||||
|
})
|
||||||
|
|
||||||
|
data['linked_orderpositions'] = [linked_orderposition] if linked_orderposition else []
|
||||||
|
del data['linked_orderposition']
|
||||||
|
|
||||||
if 'type' in data and 'identifier' in data:
|
if 'type' in data and 'identifier' in data:
|
||||||
qs = self.context['organizer'].reusable_media.filter(
|
qs = self.context['organizer'].reusable_media.filter(
|
||||||
identifier=data['identifier'], type=data['type']
|
identifier=data['identifier'], type=data['type']
|
||||||
@@ -121,14 +147,28 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
|||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
r = super().to_representation(instance)
|
r = super().to_representation(instance)
|
||||||
request = self.context.get('request')
|
request = self.context.get('request')
|
||||||
|
|
||||||
|
ops = r.get('linked_orderpositions', [])
|
||||||
# late permission evaluations for checks that depend on the actual linked events
|
# late permission evaluations for checks that depend on the actual linked events
|
||||||
expand_nested = self.context['request'].query_params.getlist('expand')
|
expand_nested = self.context['request'].query_params.getlist('expand')
|
||||||
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
|
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
|
||||||
if 'linked_orderposition' in expand_nested:
|
if ops and 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested:
|
||||||
if instance.linked_orderposition is not None:
|
ops_noperm = []
|
||||||
event = instance.linked_orderposition.order.event
|
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):
|
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
|
||||||
r['linked_orderposition'] = {'id': instance.linked_orderposition.id}
|
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:
|
if 'linked_giftcard.owner_ticket' in expand_nested:
|
||||||
gc = instance.linked_giftcard
|
gc = instance.linked_giftcard
|
||||||
@@ -148,10 +188,12 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
|||||||
'updated',
|
'updated',
|
||||||
'type',
|
'type',
|
||||||
'identifier',
|
'identifier',
|
||||||
|
'claim_token',
|
||||||
|
'label',
|
||||||
'active',
|
'active',
|
||||||
'expires',
|
'expires',
|
||||||
'customer',
|
'customer',
|
||||||
'linked_orderposition',
|
'linked_orderpositions',
|
||||||
'linked_giftcard',
|
'linked_giftcard',
|
||||||
'info',
|
'info',
|
||||||
'notes',
|
'notes',
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ from pretix.base.settings import (
|
|||||||
)
|
)
|
||||||
from pretix.base.signals import register_ticket_outputs
|
from pretix.base.signals import register_ticket_outputs
|
||||||
from pretix.helpers.countries import CachedCountries
|
from pretix.helpers.countries import CachedCountries
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -757,7 +757,7 @@ class PaymentURLField(serializers.URLField):
|
|||||||
def to_representation(self, instance: OrderPayment):
|
def to_representation(self, instance: OrderPayment):
|
||||||
if instance.state != OrderPayment.PAYMENT_STATE_CREATED:
|
if instance.state != OrderPayment.PAYMENT_STATE_CREATED:
|
||||||
return None
|
return None
|
||||||
return build_absolute_uri(instance.order.event, 'presale:event.order.pay', kwargs={
|
return eventreverse_absolute(instance.order.event, 'presale:event.order.pay', kwargs={
|
||||||
'order': instance.order.code,
|
'order': instance.order.code,
|
||||||
'secret': instance.order.secret,
|
'secret': instance.order.secret,
|
||||||
'payment': instance.pk,
|
'payment': instance.pk,
|
||||||
@@ -806,7 +806,7 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
class OrderURLField(serializers.URLField):
|
class OrderURLField(serializers.URLField):
|
||||||
def to_representation(self, instance: Order):
|
def to_representation(self, instance: Order):
|
||||||
return build_absolute_uri(instance.event, 'presale:event.order', kwargs={
|
return eventreverse_absolute(instance.event, 'presale:event.order', kwargs={
|
||||||
'order': instance.code,
|
'order': instance.code,
|
||||||
'secret': instance.secret,
|
'secret': instance.secret,
|
||||||
})
|
})
|
||||||
@@ -1149,6 +1149,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
|||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
{'discount': ['You can only specify a discount if you do the price computation, but price is not set.']}
|
{'discount': ['You can only specify a discount if you do the price computation, but price is not set.']}
|
||||||
)
|
)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -1588,7 +1589,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
pos_data['attendee_name_parts'] = {
|
pos_data['attendee_name_parts'] = {
|
||||||
'_legacy': attendee_name
|
'_legacy': attendee_name
|
||||||
}
|
}
|
||||||
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
|
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k not in ('answers', '_quotas', 'use_reusable_medium')})
|
||||||
if simulate:
|
if simulate:
|
||||||
pos.order = order._wrapped
|
pos.order = order._wrapped
|
||||||
else:
|
else:
|
||||||
@@ -1703,15 +1704,25 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
answ.options.add(*options)
|
answ.options.add(*options)
|
||||||
|
|
||||||
if use_reusable_medium:
|
if use_reusable_medium:
|
||||||
use_reusable_medium.linked_orderposition = pos
|
if pos.item.media_policy not in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW):
|
||||||
use_reusable_medium.save(update_fields=['linked_orderposition'])
|
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.log_action(
|
use_reusable_medium.log_action(
|
||||||
'pretix.reusable_medium.linked_orderposition.changed',
|
'pretix.reusable_medium.linked_orderposition.added',
|
||||||
data={
|
data={
|
||||||
'by_order': order.code,
|
'by_order': order.code,
|
||||||
'linked_orderposition': pos.pk,
|
'linked_orderposition': pos.pk,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
use_reusable_medium.touch()
|
||||||
|
|
||||||
if not simulate:
|
if not simulate:
|
||||||
for cp in delete_cps:
|
for cp in delete_cps:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext, gettext_lazy as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
@@ -58,8 +58,8 @@ from pretix.helpers.permission_migration import (
|
|||||||
OLD_TO_NEW_EVENT_COMPAT, OLD_TO_NEW_EVENT_MIGRATION,
|
OLD_TO_NEW_EVENT_COMPAT, OLD_TO_NEW_EVENT_MIGRATION,
|
||||||
OLD_TO_NEW_ORGANIZER_COMPAT, OLD_TO_NEW_ORGANIZER_MIGRATION,
|
OLD_TO_NEW_ORGANIZER_COMPAT, OLD_TO_NEW_ORGANIZER_MIGRATION,
|
||||||
)
|
)
|
||||||
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
from pretix.helpers.urls import mainreverse_absolute
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class OrganizerSerializer(I18nAwareModelSerializer):
|
|||||||
slug = serializers.CharField(read_only=True)
|
slug = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
def get_organizer_url(self, organizer):
|
def get_organizer_url(self, organizer):
|
||||||
return build_absolute_uri(organizer, 'presale:organizer.index')
|
return eventreverse_absolute(organizer, 'presale:organizer.index')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Organizer
|
model = Organizer
|
||||||
@@ -492,14 +492,16 @@ class TeamInviteSerializer(serializers.ModelSerializer):
|
|||||||
def _send_invite(self, instance):
|
def _send_invite(self, instance):
|
||||||
mail(
|
mail(
|
||||||
instance.email,
|
instance.email,
|
||||||
_('Account invitation'),
|
gettext('You\'ve been invited to join %(organizer)s') % {
|
||||||
|
'organizer': self.context['organizer'].name,
|
||||||
|
},
|
||||||
'pretixcontrol/email/invitation.txt',
|
'pretixcontrol/email/invitation.txt',
|
||||||
{
|
{
|
||||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||||
'user': self,
|
'user': self,
|
||||||
'organizer': self.context['organizer'].name,
|
'organizer': self.context['organizer'].name,
|
||||||
'team': instance.team.name,
|
'team': instance.team.name,
|
||||||
'url': build_global_uri('control:auth.invite', kwargs={
|
'url': mainreverse_absolute('control:auth.invite', kwargs={
|
||||||
'token': instance.token
|
'token': instance.token
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -574,6 +576,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
|||||||
'customer_accounts_require_login_for_order_access',
|
'customer_accounts_require_login_for_order_access',
|
||||||
'invoice_regenerate_allowed',
|
'invoice_regenerate_allowed',
|
||||||
'contact_mail',
|
'contact_mail',
|
||||||
|
'contact_url',
|
||||||
'imprint_url',
|
'imprint_url',
|
||||||
'organizer_info_text',
|
'organizer_info_text',
|
||||||
'event_list_type',
|
'event_list_type',
|
||||||
@@ -605,6 +608,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
|||||||
'cookie_consent_dialog_button_yes',
|
'cookie_consent_dialog_button_yes',
|
||||||
'cookie_consent_dialog_button_no',
|
'cookie_consent_dialog_button_no',
|
||||||
'reusable_media_active',
|
'reusable_media_active',
|
||||||
|
'reusable_media_usage_enforced',
|
||||||
'reusable_media_type_barcode',
|
'reusable_media_type_barcode',
|
||||||
'reusable_media_type_barcode_identifier_length',
|
'reusable_media_type_barcode_identifier_length',
|
||||||
'reusable_media_type_nfc_uid',
|
'reusable_media_type_nfc_uid',
|
||||||
|
|||||||
+144
-25
@@ -69,8 +69,10 @@ from pretix.base.models import (
|
|||||||
from pretix.base.models.orders import PrintLog
|
from pretix.base.models.orders import PrintLog
|
||||||
from pretix.base.permissions import AnyPermissionOf
|
from pretix.base.permissions import AnyPermissionOf
|
||||||
from pretix.base.services.checkin import (
|
from pretix.base.services.checkin import (
|
||||||
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
|
CheckInError, RequiredMediaExchangeError, RequiredQuestionsError, SQLLogic,
|
||||||
|
perform_checkin,
|
||||||
)
|
)
|
||||||
|
from pretix.base.services.media import perform_media_exchange
|
||||||
from pretix.base.signals import checkin_annulled
|
from pretix.base.signals import checkin_annulled
|
||||||
from pretix.helpers import OF_SELF
|
from pretix.helpers import OF_SELF
|
||||||
|
|
||||||
@@ -454,7 +456,8 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
|||||||
|
|
||||||
def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce,
|
def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce,
|
||||||
untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported,
|
untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported,
|
||||||
source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False):
|
source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False,
|
||||||
|
exchange_medium_type=None, exchange_medium_identifier=None):
|
||||||
if not checkinlists:
|
if not checkinlists:
|
||||||
raise ValidationError('No check-in list passed.')
|
raise ValidationError('No check-in list passed.')
|
||||||
|
|
||||||
@@ -463,6 +466,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
|||||||
|
|
||||||
device = auth if isinstance(auth, Device) else None
|
device = auth if isinstance(auth, Device) else None
|
||||||
gate = gate or (auth.gate if isinstance(auth, Device) else None)
|
gate = gate or (auth.gate if isinstance(auth, Device) else None)
|
||||||
|
medium = None
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'request': request,
|
'request': request,
|
||||||
@@ -491,6 +495,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
|||||||
)
|
)
|
||||||
raw_barcode_for_checkin = None
|
raw_barcode_for_checkin = None
|
||||||
from_revoked_secret = False
|
from_revoked_secret = False
|
||||||
|
reusable_medium_used = None
|
||||||
if simulate:
|
if simulate:
|
||||||
common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True
|
common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True
|
||||||
|
|
||||||
@@ -521,11 +526,12 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
|||||||
# with respecting the force option), or it's a reusable medium (-> proceed with that)
|
# with respecting the force option), or it's a reusable medium (-> proceed with that)
|
||||||
if not op_candidates:
|
if not op_candidates:
|
||||||
try:
|
try:
|
||||||
media = ReusableMedium.objects.select_related('linked_orderposition').active().get(
|
medium = ReusableMedium.objects.active().filter(
|
||||||
|
Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk')))
|
||||||
|
).get(
|
||||||
organizer_id=checkinlists[0].event.organizer_id,
|
organizer_id=checkinlists[0].event.organizer_id,
|
||||||
type=source_type,
|
type=source_type,
|
||||||
identifier=raw_barcode,
|
identifier=raw_barcode,
|
||||||
linked_orderposition__isnull=False,
|
|
||||||
)
|
)
|
||||||
raw_barcode_for_checkin = raw_barcode
|
raw_barcode_for_checkin = raw_barcode
|
||||||
except ReusableMedium.DoesNotExist:
|
except ReusableMedium.DoesNotExist:
|
||||||
@@ -628,7 +634,9 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
|||||||
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
|
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
|
||||||
}, status=400)
|
}, status=400)
|
||||||
else:
|
else:
|
||||||
if media.linked_orderposition.order.event_id not in list_by_event:
|
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):
|
||||||
# Medium exists but connected ticket is for the wrong event
|
# Medium exists but connected ticket is for the wrong event
|
||||||
if not simulate:
|
if not simulate:
|
||||||
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
|
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
|
||||||
@@ -654,28 +662,91 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
|||||||
'checkin_texts': [],
|
'checkin_texts': [],
|
||||||
'list': MiniCheckinListSerializer(checkinlists[0]).data,
|
'list': MiniCheckinListSerializer(checkinlists[0]).data,
|
||||||
}, status=404)
|
}, status=404)
|
||||||
op_candidates = [media.linked_orderposition]
|
op_candidates = []
|
||||||
if list_by_event[media.linked_orderposition.order.event_id].addon_match:
|
for op in linked_ops:
|
||||||
op_candidates += list(media.linked_orderposition.addons.all())
|
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())
|
||||||
|
|
||||||
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
|
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
|
||||||
# key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
|
# key on the same list, we're probably dealing with multiple linked_orderpositions or the ``addon_match`` case
|
||||||
# which add-on has the right product.
|
# here and need to figure out which op has the right product. This basically is a valid-for-checkin-test on every op.
|
||||||
if len(op_candidates) > 1:
|
if len(op_candidates) > 1:
|
||||||
op_candidates_matching_product = [
|
|
||||||
op for op in op_candidates
|
|
||||||
if (
|
|
||||||
(list_by_event[op.order.event_id].addon_match or op.secret == raw_barcode or legacy_url_support) and
|
|
||||||
(list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()})
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(op_candidates_matching_product) == 0:
|
if not reusable_medium_used:
|
||||||
# None of the found add-ons has the correct product, too bad! We could just error out here, but
|
# 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
|
||||||
# instead we just continue with *any* product and have it rejected by the check in perform_checkin.
|
# instead we just continue with *any* product and have it rejected by the check in perform_checkin.
|
||||||
# This has the advantage of a better error message.
|
# To improve the error message, we select the op that will "work next" or - if none matches - "worked last".
|
||||||
op_candidates = [op_candidates[0]]
|
op_candidate = None
|
||||||
elif len(op_candidates_matching_product) > 1:
|
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:
|
||||||
# It's still ambiguous, we'll error out.
|
# It's still ambiguous, we'll error out.
|
||||||
# We choose the first match (regardless of product) for the logging since it's most likely to be the
|
# We choose the first match (regardless of product) for the logging since it's most likely to be the
|
||||||
# base product according to our order_by above.
|
# base product according to our order_by above.
|
||||||
@@ -709,7 +780,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
|||||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||||
}, status=400)
|
}, status=400)
|
||||||
else:
|
else:
|
||||||
op_candidates = op_candidates_matching_product
|
op_candidates = op_candidates_filtered
|
||||||
|
|
||||||
op = op_candidates[0]
|
op = op_candidates[0]
|
||||||
common_checkin_args['list'] = list_by_event[op.order.event_id]
|
common_checkin_args['list'] = list_by_event[op.order.event_id]
|
||||||
@@ -721,7 +792,10 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
|||||||
if str(q.pk) in answers_data:
|
if str(q.pk) in answers_data:
|
||||||
try:
|
try:
|
||||||
if q.type == Question.TYPE_FILE:
|
if q.type == Question.TYPE_FILE:
|
||||||
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
|
if answers_data[str(q.pk)]:
|
||||||
|
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
|
||||||
|
else:
|
||||||
|
given_answers[q] = None
|
||||||
else:
|
else:
|
||||||
given_answers[q] = q.clean_answer(answers_data[str(q.pk)])
|
given_answers[q] = q.clean_answer(answers_data[str(q.pk)])
|
||||||
except (ValidationError, BaseValidationError):
|
except (ValidationError, BaseValidationError):
|
||||||
@@ -734,7 +808,14 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
|||||||
locale = op.order.event.settings.locale
|
locale = op.order.event.settings.locale
|
||||||
with language(locale):
|
with language(locale):
|
||||||
try:
|
try:
|
||||||
perform_checkin(
|
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(
|
||||||
op=op,
|
op=op,
|
||||||
clist=list_by_event[op.order.event_id],
|
clist=list_by_event[op.order.event_id],
|
||||||
given_answers=given_answers,
|
given_answers=given_answers,
|
||||||
@@ -752,7 +833,30 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
|||||||
from_revoked_secret=from_revoked_secret,
|
from_revoked_secret=from_revoked_secret,
|
||||||
simulate=simulate,
|
simulate=simulate,
|
||||||
gate=gate,
|
gate=gate,
|
||||||
|
reusable_medium=medium,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if exchange_medium_identifier: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate
|
||||||
|
if simulate:
|
||||||
|
raise CheckInError(
|
||||||
|
gettext('You cannot simulate a medium exchange.'),
|
||||||
|
'error'
|
||||||
|
)
|
||||||
|
with transaction.atomic():
|
||||||
|
# Do exchange and check-in atomically, i.e. both succeed or both fail
|
||||||
|
medium = perform_media_exchange(
|
||||||
|
organizer=request.organizer,
|
||||||
|
media_type=exchange_medium_type,
|
||||||
|
identifier=exchange_medium_identifier,
|
||||||
|
link_orderposition=op,
|
||||||
|
user=user,
|
||||||
|
auth=auth,
|
||||||
|
)
|
||||||
|
source_type = medium.media_type.identifier
|
||||||
|
checkin_args['reusable_medium'] = medium
|
||||||
|
perform_checkin(**checkin_args)
|
||||||
|
else:
|
||||||
|
perform_checkin(**checkin_args)
|
||||||
except RequiredQuestionsError as e:
|
except RequiredQuestionsError as e:
|
||||||
return Response({
|
return Response({
|
||||||
'status': 'incomplete',
|
'status': 'incomplete',
|
||||||
@@ -764,6 +868,18 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
|||||||
],
|
],
|
||||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
except RequiredMediaExchangeError as e:
|
||||||
|
return Response({
|
||||||
|
'status': 'exchange',
|
||||||
|
'require_attention': op.require_checkin_attention,
|
||||||
|
'checkin_texts': op.checkin_texts,
|
||||||
|
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
|
||||||
|
'media_policy': e.media_policy,
|
||||||
|
'media_type': e.media_type,
|
||||||
|
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||||
|
'reason': e.code,
|
||||||
|
'reason_explanation': e.msg,
|
||||||
|
}, status=400)
|
||||||
except CheckInError as e:
|
except CheckInError as e:
|
||||||
if not simulate:
|
if not simulate:
|
||||||
op.order.log_action('pretix.event.checkin.denied', data={
|
op.order.log_action('pretix.event.checkin.denied', data={
|
||||||
@@ -951,6 +1067,9 @@ class CheckinRPCRedeemView(views.APIView):
|
|||||||
canceled_supported=True,
|
canceled_supported=True,
|
||||||
request=self.request, # this is not clean, but we need it in the serializers for URL generation
|
request=self.request, # this is not clean, but we need it in the serializers for URL generation
|
||||||
legacy_url_support=False,
|
legacy_url_support=False,
|
||||||
|
exchange_medium_type=s.validated_data.get('exchange_medium_type'),
|
||||||
|
exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'),
|
||||||
|
simulate=s.validated_data.get('simulate'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -53,10 +53,12 @@ with scopes_disabled():
|
|||||||
customer = django_filters.CharFilter(field_name='customer__identifier')
|
customer = django_filters.CharFilter(field_name='customer__identifier')
|
||||||
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
|
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
|
||||||
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
|
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
|
||||||
|
# backwards-compatible
|
||||||
|
linked_orderposition = django_filters.NumberFilter(field_name='linked_orderpositions__id')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ReusableMedium
|
model = ReusableMedium
|
||||||
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard']
|
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderpositions', 'linked_giftcard']
|
||||||
|
|
||||||
|
|
||||||
class ReusableMediaViewSet(viewsets.ModelViewSet):
|
class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||||
@@ -75,7 +77,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
|||||||
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
||||||
return self.request.organizer.reusable_media.prefetch_related(
|
return self.request.organizer.reusable_media.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
'linked_orderposition',
|
'linked_orderpositions',
|
||||||
queryset=OrderPosition.objects.select_related(
|
queryset=OrderPosition.objects.select_related(
|
||||||
'order', 'order__event', 'order__event__organizer', 'seat',
|
'order', 'order__event', 'order__event__organizer', 'seat',
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
@@ -117,14 +119,38 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
|
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))
|
||||||
inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type)
|
inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type)
|
||||||
inst.log_action(
|
linked_ops_pks = inst.linked_orderpositions.values_list("pk", flat=True)
|
||||||
'pretix.reusable_medium.changed',
|
for op_pk in prev_linked_ops_pks:
|
||||||
user=self.request.user,
|
if op_pk not in linked_ops_pks:
|
||||||
auth=self.request.auth,
|
inst.log_action(
|
||||||
data=self.request.data,
|
'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,
|
||||||
|
)
|
||||||
return inst
|
return inst
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
@@ -157,7 +183,6 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
|||||||
type=s.validated_data["type"],
|
type=s.validated_data["type"],
|
||||||
identifier=s.validated_data["identifier"],
|
identifier=s.validated_data["identifier"],
|
||||||
)
|
)
|
||||||
m.linked_orderposition = None # not relevant for cross-organizer
|
|
||||||
m.customer = None # not relevant for cross-organizer
|
m.customer = None # not relevant for cross-organizer
|
||||||
s = self.get_serializer(m)
|
s = self.get_serializer(m)
|
||||||
return Response({"result": s.data})
|
return Response({"result": s.data})
|
||||||
@@ -171,7 +196,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
return Response({"result": None})
|
return Response({"result": None})
|
||||||
|
|
||||||
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
|
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some performance
|
||||||
def list(self, request, **kwargs):
|
def list(self, request, **kwargs):
|
||||||
date = serializers.DateTimeField().to_representation(now())
|
date = serializers.DateTimeField().to_representation(now())
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ with scopes_disabled():
|
|||||||
)
|
)
|
||||||
).values('id')
|
).values('id')
|
||||||
|
|
||||||
matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderposition__order_id', flat=True)
|
matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderpositions__order_id', flat=True)
|
||||||
|
|
||||||
mainq = (
|
mainq = (
|
||||||
code
|
code
|
||||||
@@ -1034,7 +1034,7 @@ with scopes_disabled():
|
|||||||
search = django_filters.CharFilter(method='search_qs')
|
search = django_filters.CharFilter(method='search_qs')
|
||||||
|
|
||||||
def search_qs(self, queryset, name, value):
|
def search_qs(self, queryset, name, value):
|
||||||
matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderposition', flat=True)
|
matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderpositions', flat=True)
|
||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
Q(secret__istartswith=value)
|
Q(secret__istartswith=value)
|
||||||
| Q(attendee_name_cached__icontains=value)
|
| Q(attendee_name_cached__icontains=value)
|
||||||
|
|||||||
@@ -408,6 +408,12 @@ def register_default_webhook_events(sender, **kwargs):
|
|||||||
_('This includes product added or deleted and changes to nested objects like '
|
_('This includes product added or deleted and changes to nested objects like '
|
||||||
'variations or bundles.'),
|
'variations or bundles.'),
|
||||||
),
|
),
|
||||||
|
ParametrizedItemWebhookEvent(
|
||||||
|
'pretix.event.quota.*',
|
||||||
|
_('Quota changed'),
|
||||||
|
_('This includes related events like creation, deletion, opening or closing of quotas. '
|
||||||
|
'No webhook is sent for changes to the resulting availability.'),
|
||||||
|
),
|
||||||
ParametrizedEventWebhookEvent(
|
ParametrizedEventWebhookEvent(
|
||||||
'pretix.event.live.activated',
|
'pretix.event.live.activated',
|
||||||
_('Shop taken live'),
|
_('Shop taken live'),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from requests import RequestException
|
from requests import RequestException
|
||||||
|
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@ def _get_or_create_server_keypair(organizer):
|
|||||||
|
|
||||||
def generate_id_token(customer, client, auth_time, nonce, scope, expires: datetime, scope_claims=False, with_code=None, with_access_token=None):
|
def generate_id_token(customer, client, auth_time, nonce, scope, expires: datetime, scope_claims=False, with_code=None, with_access_token=None):
|
||||||
payload = {
|
payload = {
|
||||||
'iss': build_absolute_uri(client.organizer, 'presale:organizer.index').rstrip('/'),
|
'iss': eventreverse_absolute(client.organizer, 'presale:organizer.index').rstrip('/'),
|
||||||
'aud': client.client_id,
|
'aud': client.client_id,
|
||||||
'exp': int(expires.timestamp()),
|
'exp': int(expires.timestamp()),
|
||||||
'iat': int(time.time()),
|
'iat': int(time.time()),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
|||||||
|
|
||||||
from pretix.base.models import Checkin, InvoiceAddress, Order, Question
|
from pretix.base.models import Checkin, InvoiceAddress, Order, Question
|
||||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||||
|
|
||||||
|
|
||||||
def get_answer(op, question_identifier=None):
|
def get_answer(op, question_identifier=None):
|
||||||
@@ -545,7 +545,7 @@ def get_data_fields(event, for_model=None):
|
|||||||
_("Order link"),
|
_("Order link"),
|
||||||
Question.TYPE_STRING,
|
Question.TYPE_STRING,
|
||||||
None,
|
None,
|
||||||
lambda order: build_absolute_uri(
|
lambda order: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order', kwargs={
|
'presale:event.order', kwargs={
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
@@ -560,7 +560,7 @@ def get_data_fields(event, for_model=None):
|
|||||||
_("Ticket link"),
|
_("Ticket link"),
|
||||||
Question.TYPE_STRING,
|
Question.TYPE_STRING,
|
||||||
None,
|
None,
|
||||||
lambda op: build_absolute_uri(
|
lambda op: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.position', kwargs={
|
'presale:event.order.position', kwargs={
|
||||||
'order': op.order.code,
|
'order': op.order.code,
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ logger = logging.getLogger('pretix.base.email')
|
|||||||
|
|
||||||
T = TypeVar("T", bound=EmailBackend)
|
T = TypeVar("T", bound=EmailBackend)
|
||||||
|
|
||||||
|
_cgnat_net = ipaddress.ip_network('100.64.0.0/10')
|
||||||
|
|
||||||
|
|
||||||
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
|
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -253,12 +255,15 @@ def create_connection(address, timeout=socket.getdefaulttimeout(),
|
|||||||
|
|
||||||
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
|
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
|
||||||
ip_addr = ipaddress.ip_address(sa[0])
|
ip_addr = ipaddress.ip_address(sa[0])
|
||||||
|
check_ip4 = ip_addr.ipv4_mapped if getattr(ip_addr, "ipv4_mapped", None) else ip_addr
|
||||||
if ip_addr.is_multicast:
|
if ip_addr.is_multicast:
|
||||||
raise socket.error(f"Request to multicast address {sa[0]} blocked")
|
raise socket.error(f"Request to multicast address {sa[0]} blocked")
|
||||||
if ip_addr.is_loopback or ip_addr.is_link_local:
|
if ip_addr.is_loopback or ip_addr.is_link_local:
|
||||||
raise socket.error(f"Request to local address {sa[0]} blocked")
|
raise socket.error(f"Request to local address {sa[0]} blocked")
|
||||||
if ip_addr.is_private:
|
if ip_addr.is_private:
|
||||||
raise socket.error(f"Request to private address {sa[0]} blocked")
|
raise socket.error(f"Request to private address {sa[0]} blocked")
|
||||||
|
if check_ip4 in _cgnat_net:
|
||||||
|
raise socket.error(f"Request to RFC 6598 address {sa[0]} blocked")
|
||||||
|
|
||||||
sock = None
|
sock = None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ from ...control.forms.filter import get_all_payment_providers
|
|||||||
from ...helpers import GroupConcat
|
from ...helpers import GroupConcat
|
||||||
from ...helpers.iter import chunked_iterable
|
from ...helpers.iter import chunked_iterable
|
||||||
from ...helpers.safe_openpyxl import remove_invalid_excel_chars
|
from ...helpers.safe_openpyxl import remove_invalid_excel_chars
|
||||||
from ...multidomain.urlreverse import build_absolute_uri
|
from ...multidomain.urlreverse import eventreverse_absolute
|
||||||
from ..exporter import (
|
from ..exporter import (
|
||||||
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
|
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
|
||||||
)
|
)
|
||||||
@@ -160,7 +160,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
|
|
||||||
def _get_all_payment_methods(self, qs):
|
def _get_all_payment_methods(self, qs):
|
||||||
pps = dict(get_all_payment_providers())
|
pps = dict(get_all_payment_providers())
|
||||||
return sorted([(pp, pps[pp]) for pp in set(
|
return sorted([(pp, pps.get(pp, pp)) for pp in set(
|
||||||
OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list(
|
OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list(
|
||||||
'provider', flat=True
|
'provider', flat=True
|
||||||
).distinct()
|
).distinct()
|
||||||
@@ -330,6 +330,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
taxsum=Sum('tax_value'), grosssum=Sum('value')
|
taxsum=Sum('tax_value'), grosssum=Sum('value')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
payment_methods = None
|
||||||
if form_data.get('include_payment_amounts'):
|
if form_data.get('include_payment_amounts'):
|
||||||
payment_sum_cache = {
|
payment_sum_cache = {
|
||||||
(o['order__id'], o['provider']): o['grosssum'] for o in
|
(o['order__id'], o['provider']): o['grosssum'] for o in
|
||||||
@@ -347,6 +348,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
grosssum=Sum('amount')
|
grosssum=Sum('amount')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
payment_methods = self._get_all_payment_methods(qs)
|
||||||
sum_cache = {
|
sum_cache = {
|
||||||
(o['order__id'], o['tax_rate']): o for o in
|
(o['order__id'], o['tax_rate']): o for o in
|
||||||
OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate(
|
OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate(
|
||||||
@@ -427,14 +429,13 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
]))
|
]))
|
||||||
|
|
||||||
row.append(
|
row.append(
|
||||||
build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
eventreverse_absolute(order.event, 'presale:event.order', kwargs={
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
'secret': order.secret,
|
'secret': order.secret,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
if form_data.get('include_payment_amounts'):
|
if form_data.get('include_payment_amounts'):
|
||||||
payment_methods = self._get_all_payment_methods(qs)
|
|
||||||
for id, vn in payment_methods:
|
for id, vn in payment_methods:
|
||||||
row.append(
|
row.append(
|
||||||
payment_sum_cache.get((order.id, id), Decimal('0.00')) -
|
payment_sum_cache.get((order.id, id), Decimal('0.00')) -
|
||||||
@@ -854,7 +855,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
]))
|
]))
|
||||||
|
|
||||||
row.append(
|
row.append(
|
||||||
build_absolute_uri(order.event, 'presale:event.order.position', kwargs={
|
eventreverse_absolute(order.event, 'presale:event.order.position', kwargs={
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
'secret': op.web_secret,
|
'secret': op.web_secret,
|
||||||
'position': op.positionid
|
'position': op.positionid
|
||||||
|
|||||||
@@ -20,12 +20,13 @@
|
|||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
|
||||||
|
from django.db.models import Prefetch
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy
|
from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy
|
||||||
|
|
||||||
from ..exporter import ListExporter, OrganizerLevelExportMixin
|
from ..exporter import ListExporter, OrganizerLevelExportMixin
|
||||||
from ..models import ReusableMedium
|
from ..models import OrderPosition, ReusableMedium
|
||||||
from ..signals import register_multievent_data_exporters
|
from ..signals import register_multievent_data_exporters
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +45,9 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
|
|||||||
media = ReusableMedium.objects.filter(
|
media = ReusableMedium.objects.filter(
|
||||||
organizer=self.organizer,
|
organizer=self.organizer,
|
||||||
).select_related(
|
).select_related(
|
||||||
'customer', 'linked_orderposition', 'linked_giftcard',
|
'customer', 'linked_giftcard',
|
||||||
|
).prefetch_related(
|
||||||
|
Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order"))
|
||||||
).order_by('created')
|
).order_by('created')
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
@@ -61,18 +64,23 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
|
|||||||
yield headers
|
yield headers
|
||||||
yield self.ProgressSetTotal(total=media.count())
|
yield self.ProgressSetTotal(total=media.count())
|
||||||
|
|
||||||
|
can_read_giftcards = self.permission_holder.has_organizer_permission(self.organizer, 'organizer.giftcards:read')
|
||||||
|
|
||||||
for medium in media.iterator(chunk_size=1000):
|
for medium in media.iterator(chunk_size=1000):
|
||||||
row = [
|
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 [
|
||||||
medium.type,
|
medium.type,
|
||||||
medium.identifier,
|
medium.identifier,
|
||||||
_('Yes') if medium.active else _('No'),
|
_('Yes') if medium.active else _('No'),
|
||||||
date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '',
|
date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '',
|
||||||
medium.customer.identifier if medium.customer_id else '',
|
medium.customer.identifier if medium.customer_id else '',
|
||||||
f"{medium.linked_orderposition.order.code}-{medium.linked_orderposition.positionid}" if medium.linked_orderposition_id else '',
|
', '.join([f"{op.order.code}-{op.positionid}" for op in medium.linked_orderpositions.all()]),
|
||||||
medium.linked_giftcard.secret if medium.linked_giftcard_id else '',
|
giftcard_secret,
|
||||||
medium.notes,
|
medium.notes,
|
||||||
]
|
]
|
||||||
yield row
|
|
||||||
|
|
||||||
def get_filename(self):
|
def get_filename(self):
|
||||||
return f'{self.organizer.slug}_media'
|
return f'{self.organizer.slug}_media'
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@@ -47,13 +48,12 @@ from django.conf import settings
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.core.validators import (
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
MaxValueValidator, MinValueValidator, RegexValidator,
|
|
||||||
)
|
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.forms import Select, widgets
|
from django.forms import Select, widgets
|
||||||
from django.forms.widgets import FILE_INPUT_CONTRADICTION
|
from django.forms.widgets import FILE_INPUT_CONTRADICTION
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
|
from django.utils.functional import lazy
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.text import format_lazy
|
from django.utils.text import format_lazy
|
||||||
@@ -220,20 +220,6 @@ class NamePartsFormField(forms.MultiValueField):
|
|||||||
defaults = {
|
defaults = {
|
||||||
'widget': self.widget,
|
'widget': self.widget,
|
||||||
'max_length': kwargs.pop('max_length', None),
|
'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.')
|
|
||||||
),
|
|
||||||
RegexValidator(
|
|
||||||
URL_RE,
|
|
||||||
inverse_match=True,
|
|
||||||
message=_('Please do not use special characters in names.')
|
|
||||||
)
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
self.max_length = defaults['max_length']
|
self.max_length = defaults['max_length']
|
||||||
self.scheme_name = kwargs.pop('scheme')
|
self.scheme_name = kwargs.pop('scheme')
|
||||||
@@ -255,7 +241,6 @@ class NamePartsFormField(forms.MultiValueField):
|
|||||||
if fname == 'title' and self.scheme_titles:
|
if fname == 'title' and self.scheme_titles:
|
||||||
d = dict(defaults)
|
d = dict(defaults)
|
||||||
d.pop('max_length', None)
|
d.pop('max_length', None)
|
||||||
d.pop('validators', None)
|
|
||||||
field = forms.ChoiceField(
|
field = forms.ChoiceField(
|
||||||
**d,
|
**d,
|
||||||
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
|
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
|
||||||
@@ -264,7 +249,6 @@ class NamePartsFormField(forms.MultiValueField):
|
|||||||
elif fname == 'salutation':
|
elif fname == 'salutation':
|
||||||
d = dict(defaults)
|
d = dict(defaults)
|
||||||
d.pop('max_length', None)
|
d.pop('max_length', None)
|
||||||
d.pop('validators', None)
|
|
||||||
field = forms.ChoiceField(
|
field = forms.ChoiceField(
|
||||||
**d,
|
**d,
|
||||||
choices=[
|
choices=[
|
||||||
@@ -296,6 +280,37 @@ class NamePartsFormField(forms.MultiValueField):
|
|||||||
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) > (self.max_length or 250):
|
||||||
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
|
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":
|
if value.get("salutation") == "empty":
|
||||||
value["salutation"] = ""
|
value["salutation"] = ""
|
||||||
|
|
||||||
@@ -310,16 +325,21 @@ class WrappedPhonePrefixSelect(Select):
|
|||||||
initial = None
|
initial = None
|
||||||
|
|
||||||
def __init__(self, initial=None):
|
def __init__(self, initial=None):
|
||||||
choices = [("", "---------")]
|
def _get_choices():
|
||||||
|
choices = [("", "---------")]
|
||||||
|
|
||||||
|
if initial:
|
||||||
|
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
|
||||||
|
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
|
||||||
|
continue
|
||||||
|
if initial in values:
|
||||||
|
self.initial = "+%d" % prefix
|
||||||
|
break
|
||||||
|
choices += get_phone_prefixes_sorted_and_localized()
|
||||||
|
return choices
|
||||||
|
|
||||||
|
choices = lazy(_get_choices, list)()
|
||||||
|
|
||||||
if initial:
|
|
||||||
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
|
|
||||||
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
|
|
||||||
continue
|
|
||||||
if initial in values:
|
|
||||||
self.initial = "+%d" % prefix
|
|
||||||
break
|
|
||||||
choices += get_phone_prefixes_sorted_and_localized()
|
|
||||||
super().__init__(choices=choices, attrs={
|
super().__init__(choices=choices, attrs={
|
||||||
'aria-label': pgettext_lazy('phonenumber', 'International area code'),
|
'aria-label': pgettext_lazy('phonenumber', 'International area code'),
|
||||||
'autocomplete': 'tel-country-code',
|
'autocomplete': 'tel-country-code',
|
||||||
@@ -1384,7 +1404,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
elif self.validate_vat_id and vat_id_applicable:
|
elif self.validate_vat_id and vat_id_applicable:
|
||||||
try:
|
try:
|
||||||
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
|
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
|
||||||
self.instance.vat_id_validated = True
|
self.instance.vat_id_validated = bool(normalized_id)
|
||||||
self.instance.vat_id = data['vat_id'] = normalized_id
|
self.instance.vat_id = data['vat_id'] = normalized_id
|
||||||
except VATIDFinalError as e:
|
except VATIDFinalError as e:
|
||||||
if self.all_optional:
|
if self.all_optional:
|
||||||
|
|||||||
@@ -22,9 +22,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import re
|
|
||||||
import textwrap
|
import textwrap
|
||||||
import unicodedata
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@@ -58,8 +56,8 @@ from pretix.base.services.currencies import SOURCE_NAMES
|
|||||||
from pretix.base.signals import register_invoice_renderers
|
from pretix.base.signals import register_invoice_renderers
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
from pretix.helpers.reportlab import (
|
from pretix.helpers.reportlab import (
|
||||||
FontFallbackParagraph, ThumbnailingImageReader, register_ttf_font_if_new,
|
FontFallbackParagraph, PlainTextParagraph, ThumbnailingImageReader,
|
||||||
reshaper,
|
normalize_text, register_ttf_font_if_new, reshaper,
|
||||||
)
|
)
|
||||||
from pretix.presale.style import get_fonts
|
from pretix.presale.style import get_fonts
|
||||||
|
|
||||||
@@ -259,18 +257,8 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
|||||||
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
|
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
|
||||||
|
|
||||||
def _normalize(self, text):
|
def _normalize(self, text):
|
||||||
# reportlab does not support unicode combination characters
|
# alias kept for plugin compatibility
|
||||||
# It's important we do this before we use ArabicReshaper
|
return normalize_text(text)
|
||||||
text = unicodedata.normalize("NFKC", text)
|
|
||||||
|
|
||||||
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
|
|
||||||
# to resolve all ligatures and python-bidi to switch RTL texts.
|
|
||||||
try:
|
|
||||||
text = "<br />".join(get_display(reshaper.reshape(l)) for l in re.split("<br ?/>", text))
|
|
||||||
except:
|
|
||||||
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
|
|
||||||
|
|
||||||
return text
|
|
||||||
|
|
||||||
def _upper(self, val):
|
def _upper(self, val):
|
||||||
# We uppercase labels, but not in every language
|
# We uppercase labels, but not in every language
|
||||||
@@ -351,10 +339,15 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
|||||||
return 'invoice.pdf', 'application/pdf', buffer.read()
|
return 'invoice.pdf', 'application/pdf', buffer.read()
|
||||||
|
|
||||||
def _clean_text(self, text, tags=None):
|
def _clean_text(self, text, tags=None):
|
||||||
return self._normalize(bleach.clean(
|
# For backwards compatibility with customer content, we need to support tags like <br> and <b> in a few text
|
||||||
text,
|
# fields. Therefore, we can't use PlainTextParagraph for these, but run bleach instead to limit the allowed
|
||||||
tags=set(tags) if tags else set()
|
# tags.
|
||||||
).strip().replace('<br>', '<br />').replace('\n', '<br />\n'))
|
return self._normalize(
|
||||||
|
bleach.clean(
|
||||||
|
text,
|
||||||
|
tags=set(tags) if tags else set()
|
||||||
|
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PaidMarker(Flowable):
|
class PaidMarker(Flowable):
|
||||||
@@ -405,8 +398,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
invoice_to_top = 52 * mm
|
invoice_to_top = 52 * mm
|
||||||
|
|
||||||
def _draw_invoice_to(self, canvas):
|
def _draw_invoice_to(self, canvas):
|
||||||
p = FontFallbackParagraph(self._clean_text(self.invoice.address_invoice_to),
|
p = PlainTextParagraph(self.invoice.address_invoice_to, style=self.stylesheet['Normal'])
|
||||||
style=self.stylesheet['Normal'])
|
|
||||||
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
|
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
|
||||||
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
|
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
|
||||||
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
|
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
|
||||||
@@ -417,8 +409,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
invoice_from_top = 17 * mm
|
invoice_from_top = 17 * mm
|
||||||
|
|
||||||
def _draw_invoice_from(self, canvas):
|
def _draw_invoice_from(self, canvas):
|
||||||
p = FontFallbackParagraph(
|
p = PlainTextParagraph(
|
||||||
self._clean_text(self.invoice.full_invoice_from),
|
self.invoice.full_invoice_from,
|
||||||
style=self.stylesheet['InvoiceFrom']
|
style=self.stylesheet['InvoiceFrom']
|
||||||
)
|
)
|
||||||
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
|
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
|
||||||
@@ -548,13 +540,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
def _draw_event(self, canvas):
|
def _draw_event(self, canvas):
|
||||||
def shorten(txt):
|
def shorten(txt):
|
||||||
txt = str(txt)
|
txt = str(txt)
|
||||||
txt = bleach.clean(txt, tags=set()).strip()
|
p = PlainTextParagraph(txt, style=self.stylesheet['Normal'])
|
||||||
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
|
|
||||||
p_size = p.wrap(self.event_width, self.event_height)
|
p_size = p.wrap(self.event_width, self.event_height)
|
||||||
|
|
||||||
while p_size[1] > 2 * self.stylesheet['Normal'].leading:
|
while p_size[1] > 2 * self.stylesheet['Normal'].leading:
|
||||||
txt = ' '.join(txt.replace('…', '').split()[:-1]) + '…'
|
txt = ' '.join(txt.replace('…', '').split()[:-1]) + '…'
|
||||||
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
|
p = PlainTextParagraph(txt, style=self.stylesheet['Normal'])
|
||||||
p_size = p.wrap(self.event_width, self.event_height)
|
p_size = p.wrap(self.event_width, self.event_height)
|
||||||
return txt
|
return txt
|
||||||
|
|
||||||
@@ -572,7 +563,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
else:
|
else:
|
||||||
p_str = shorten(self.invoice.event.name)
|
p_str = shorten(self.invoice.event.name)
|
||||||
|
|
||||||
p = FontFallbackParagraph(self._normalize(p_str.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
|
p = PlainTextParagraph(p_str, style=self.stylesheet['Normal'])
|
||||||
p.wrapOn(canvas, self.event_width, self.event_height)
|
p.wrapOn(canvas, self.event_width, self.event_height)
|
||||||
p_size = p.wrap(self.event_width, self.event_height)
|
p_size = p.wrap(self.event_width, self.event_height)
|
||||||
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
|
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
|
||||||
@@ -645,39 +636,37 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
|
|
||||||
type_info_text = self.invoice.transmission_type_instance.pdf_info_text()
|
type_info_text = self.invoice.transmission_type_instance.pdf_info_text()
|
||||||
if type_info_text:
|
if type_info_text:
|
||||||
story.append(FontFallbackParagraph(
|
story.append(PlainTextParagraph(
|
||||||
type_info_text,
|
type_info_text,
|
||||||
self.stylesheet['WarningBlock']
|
self.stylesheet['WarningBlock']
|
||||||
))
|
))
|
||||||
|
|
||||||
if self.invoice.custom_field:
|
if self.invoice.custom_field:
|
||||||
story.append(FontFallbackParagraph(
|
story.append(PlainTextParagraph(
|
||||||
'{}: {}'.format(
|
'{}: {}'.format(
|
||||||
self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)),
|
str(self.invoice.event.settings.invoice_address_custom_field),
|
||||||
self._clean_text(self.invoice.custom_field),
|
self.invoice.custom_field,
|
||||||
),
|
),
|
||||||
self.stylesheet['Normal']
|
self.stylesheet['Normal']
|
||||||
))
|
))
|
||||||
|
|
||||||
if self.invoice.internal_reference:
|
if self.invoice.internal_reference:
|
||||||
story.append(FontFallbackParagraph(
|
story.append(PlainTextParagraph(
|
||||||
self._normalize(pgettext('invoice', 'Customer reference: {reference}').format(
|
pgettext('invoice', 'Customer reference: {reference}').format(
|
||||||
reference=self._clean_text(self.invoice.internal_reference),
|
reference=self.invoice.internal_reference,
|
||||||
)),
|
),
|
||||||
self.stylesheet['Normal']
|
self.stylesheet['Normal']
|
||||||
))
|
))
|
||||||
|
|
||||||
if self.invoice.invoice_to_vat_id:
|
if self.invoice.invoice_to_vat_id:
|
||||||
story.append(FontFallbackParagraph(
|
story.append(PlainTextParagraph(
|
||||||
self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' +
|
pgettext('invoice', 'Customer VAT ID') + ': ' + self.invoice.invoice_to_vat_id,
|
||||||
self._clean_text(self.invoice.invoice_to_vat_id),
|
|
||||||
self.stylesheet['Normal']
|
self.stylesheet['Normal']
|
||||||
))
|
))
|
||||||
|
|
||||||
if self.invoice.invoice_to_beneficiary:
|
if self.invoice.invoice_to_beneficiary:
|
||||||
story.append(FontFallbackParagraph(
|
story.append(PlainTextParagraph(
|
||||||
self._normalize(pgettext('invoice', 'Beneficiary')) + ':<br />' +
|
pgettext('invoice', 'Beneficiary') + ':\n' + self.invoice.invoice_to_beneficiary,
|
||||||
self._clean_text(self.invoice.invoice_to_beneficiary),
|
|
||||||
self.stylesheet['Normal']
|
self.stylesheet['Normal']
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -707,11 +696,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
|
|
||||||
story = [
|
story = [
|
||||||
NextPageTemplate('FirstPage'),
|
NextPageTemplate('FirstPage'),
|
||||||
FontFallbackParagraph(
|
PlainTextParagraph(
|
||||||
self._normalize(
|
(
|
||||||
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
|
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
|
||||||
else pgettext('invoice', 'Invoice')
|
else pgettext('invoice', 'Invoice')
|
||||||
) if not self.invoice.is_cancellation else self._normalize(pgettext('invoice', 'Cancellation')),
|
) if not self.invoice.is_cancellation else pgettext('invoice', 'Cancellation'),
|
||||||
self.stylesheet['Heading1']
|
self.stylesheet['Heading1']
|
||||||
),
|
),
|
||||||
Spacer(1, 5 * mm),
|
Spacer(1, 5 * mm),
|
||||||
@@ -733,17 +722,17 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
]
|
]
|
||||||
if has_taxes:
|
if has_taxes:
|
||||||
tdata = [(
|
tdata = [(
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
|
PlainTextParagraph(pgettext('invoice', 'Description'), self.stylesheet['Bold']),
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
|
PlainTextParagraph(pgettext('invoice', 'Qty'), self.stylesheet['BoldRightNoSplit']),
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']),
|
PlainTextParagraph(pgettext('invoice', 'Tax rate'), self.stylesheet['BoldRightNoSplit']),
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']),
|
PlainTextParagraph(pgettext('invoice', 'Net'), self.stylesheet['BoldRightNoSplit']),
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']),
|
PlainTextParagraph(pgettext('invoice', 'Gross'), self.stylesheet['BoldRightNoSplit']),
|
||||||
)]
|
)]
|
||||||
else:
|
else:
|
||||||
tdata = [(
|
tdata = [(
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
|
PlainTextParagraph(pgettext('invoice', 'Description'), self.stylesheet['Bold']),
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
|
PlainTextParagraph(pgettext('invoice', 'Qty'), self.stylesheet['BoldRightNoSplit']),
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
|
PlainTextParagraph(pgettext('invoice', 'Amount'), self.stylesheet['BoldRightNoSplit']),
|
||||||
)]
|
)]
|
||||||
|
|
||||||
def _group_key(line):
|
def _group_key(line):
|
||||||
@@ -780,8 +769,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
max_height = self.stylesheet['Normal'].leading * 5
|
max_height = self.stylesheet['Normal'].leading * 5
|
||||||
p_style = self.stylesheet['Normal']
|
p_style = self.stylesheet['Normal']
|
||||||
for __ in range(1000):
|
for __ in range(1000):
|
||||||
p = FontFallbackParagraph(
|
p = PlainTextParagraph(
|
||||||
self._clean_text(curr_description, tags=['br']),
|
curr_description,
|
||||||
p_style
|
p_style
|
||||||
)
|
)
|
||||||
h = p.wrap(max_width, doc.height)[1]
|
h = p.wrap(max_width, doc.height)[1]
|
||||||
@@ -862,7 +851,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
# Group together at the end of the invoice
|
# Group together at the end of the invoice
|
||||||
request_show_service_date = period_line
|
request_show_service_date = period_line
|
||||||
elif period_line:
|
elif period_line:
|
||||||
description_p_list.append(FontFallbackParagraph(
|
description_p_list.append(PlainTextParagraph(
|
||||||
period_line,
|
period_line,
|
||||||
self.stylesheet['Fineprint']
|
self.stylesheet['Fineprint']
|
||||||
))
|
))
|
||||||
@@ -874,7 +863,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
net_price=money_filter(net_value, self.invoice.event.currency),
|
net_price=money_filter(net_value, self.invoice.event.currency),
|
||||||
gross_price=money_filter(gross_value, self.invoice.event.currency),
|
gross_price=money_filter(gross_value, self.invoice.event.currency),
|
||||||
)
|
)
|
||||||
description_p_list.append(FontFallbackParagraph(
|
description_p_list.append(PlainTextParagraph(
|
||||||
single_price_line,
|
single_price_line,
|
||||||
self.stylesheet['Fineprint']
|
self.stylesheet['Fineprint']
|
||||||
))
|
))
|
||||||
@@ -883,11 +872,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
description_p_list.pop(0),
|
description_p_list.pop(0),
|
||||||
str(len(lines)),
|
str(len(lines)),
|
||||||
localize(tax_rate) + " %",
|
localize(tax_rate) + " %",
|
||||||
FontFallbackParagraph(
|
PlainTextParagraph(
|
||||||
money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
||||||
self.stylesheet['NormalRight']
|
self.stylesheet['NormalRight']
|
||||||
),
|
),
|
||||||
FontFallbackParagraph(
|
PlainTextParagraph(
|
||||||
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
||||||
self.stylesheet['NormalRight']
|
self.stylesheet['NormalRight']
|
||||||
),
|
),
|
||||||
@@ -904,14 +893,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
single_price_line = pgettext('invoice', 'Single price: {price}').format(
|
single_price_line = pgettext('invoice', 'Single price: {price}').format(
|
||||||
price=money_filter(gross_value, self.invoice.event.currency),
|
price=money_filter(gross_value, self.invoice.event.currency),
|
||||||
)
|
)
|
||||||
description_p_list.append(FontFallbackParagraph(
|
description_p_list.append(PlainTextParagraph(
|
||||||
single_price_line,
|
single_price_line,
|
||||||
self.stylesheet['Fineprint']
|
self.stylesheet['Fineprint']
|
||||||
))
|
))
|
||||||
tdata.append((
|
tdata.append((
|
||||||
description_p_list.pop(0),
|
description_p_list.pop(0),
|
||||||
str(len(lines)),
|
str(len(lines)),
|
||||||
FontFallbackParagraph(
|
PlainTextParagraph(
|
||||||
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
||||||
self.stylesheet['NormalRight']
|
self.stylesheet['NormalRight']
|
||||||
),
|
),
|
||||||
@@ -944,12 +933,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
|
|
||||||
if has_taxes:
|
if has_taxes:
|
||||||
tdata.append([
|
tdata.append([
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
|
PlainTextParagraph(pgettext('invoice', 'Invoice total'), self.stylesheet['Bold']), '', '', '',
|
||||||
money_filter(total, self.invoice.event.currency)
|
money_filter(total, self.invoice.event.currency)
|
||||||
])
|
])
|
||||||
else:
|
else:
|
||||||
tdata.append([
|
tdata.append([
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
|
PlainTextParagraph(pgettext('invoice', 'Invoice total'), self.stylesheet['Bold']), '',
|
||||||
money_filter(total, self.invoice.event.currency)
|
money_filter(total, self.invoice.event.currency)
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -958,12 +947,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
pending_sum = self.invoice.order.pending_sum
|
pending_sum = self.invoice.order.pending_sum
|
||||||
if pending_sum != total:
|
if pending_sum != total:
|
||||||
tdata.append(
|
tdata.append(
|
||||||
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] +
|
[PlainTextParagraph(pgettext('invoice', 'Received payments'), self.stylesheet['Normal'])] +
|
||||||
(['', '', ''] if has_taxes else ['']) +
|
(['', '', ''] if has_taxes else ['']) +
|
||||||
[money_filter(pending_sum - total, self.invoice.event.currency)]
|
[money_filter(pending_sum - total, self.invoice.event.currency)]
|
||||||
)
|
)
|
||||||
tdata.append(
|
tdata.append(
|
||||||
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] +
|
[PlainTextParagraph(pgettext('invoice', 'Outstanding payments'), self.stylesheet['Bold'])] +
|
||||||
(['', '', ''] if has_taxes else ['']) +
|
(['', '', ''] if has_taxes else ['']) +
|
||||||
[money_filter(pending_sum, self.invoice.event.currency)]
|
[money_filter(pending_sum, self.invoice.event.currency)]
|
||||||
)
|
)
|
||||||
@@ -980,12 +969,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
s=Sum('amount')
|
s=Sum('amount')
|
||||||
)['s'] or Decimal('0.00')
|
)['s'] or Decimal('0.00')
|
||||||
tdata.append(
|
tdata.append(
|
||||||
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] +
|
[PlainTextParagraph(pgettext('invoice', 'Paid by gift card'), self.stylesheet['Normal'])] +
|
||||||
(['', '', ''] if has_taxes else ['']) +
|
(['', '', ''] if has_taxes else ['']) +
|
||||||
[money_filter(giftcard_sum, self.invoice.event.currency)]
|
[money_filter(giftcard_sum, self.invoice.event.currency)]
|
||||||
)
|
)
|
||||||
tdata.append(
|
tdata.append(
|
||||||
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] +
|
[PlainTextParagraph(pgettext('invoice', 'Remaining amount'), self.stylesheet['Bold'])] +
|
||||||
(['', '', ''] if has_taxes else ['']) +
|
(['', '', ''] if has_taxes else ['']) +
|
||||||
[money_filter(total - giftcard_sum, self.invoice.event.currency)]
|
[money_filter(total - giftcard_sum, self.invoice.event.currency)]
|
||||||
)
|
)
|
||||||
@@ -1008,14 +997,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
story.append(Spacer(1, 10 * mm))
|
story.append(Spacer(1, 10 * mm))
|
||||||
|
|
||||||
if request_show_service_date:
|
if request_show_service_date:
|
||||||
story.append(FontFallbackParagraph(
|
story.append(PlainTextParagraph(
|
||||||
self._normalize(pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date)),
|
pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date),
|
||||||
self.stylesheet['Normal']
|
self.stylesheet['Normal']
|
||||||
))
|
))
|
||||||
|
|
||||||
if self.invoice.payment_provider_text:
|
if self.invoice.payment_provider_text:
|
||||||
story.append(FontFallbackParagraph(
|
story.append(FontFallbackParagraph(
|
||||||
self._normalize(self.invoice.payment_provider_text),
|
self._clean_text(self.invoice.payment_provider_text, tags=['br', 'b']),
|
||||||
self.stylesheet['Normal']
|
self.stylesheet['Normal']
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -1039,10 +1028,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
|
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
|
||||||
]
|
]
|
||||||
thead = [
|
thead = [
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']),
|
PlainTextParagraph(pgettext('invoice', 'Tax rate'), self.stylesheet['Fineprint']),
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']),
|
PlainTextParagraph(pgettext('invoice', 'Net value'), self.stylesheet['FineprintRight']),
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']),
|
PlainTextParagraph(pgettext('invoice', 'Gross value'), self.stylesheet['FineprintRight']),
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']),
|
PlainTextParagraph(pgettext('invoice', 'Tax'), self.stylesheet['FineprintRight']),
|
||||||
''
|
''
|
||||||
]
|
]
|
||||||
tdata = [thead]
|
tdata = [thead]
|
||||||
@@ -1053,7 +1042,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
continue
|
continue
|
||||||
tax = taxvalue_map[idx]
|
tax = taxvalue_map[idx]
|
||||||
tdata.append([
|
tdata.append([
|
||||||
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
|
PlainTextParagraph(localize(rate) + " % " + name, self.stylesheet['Fineprint']),
|
||||||
money_filter(gross - tax, self.invoice.event.currency),
|
money_filter(gross - tax, self.invoice.event.currency),
|
||||||
money_filter(gross, self.invoice.event.currency),
|
money_filter(gross, self.invoice.event.currency),
|
||||||
money_filter(tax, self.invoice.event.currency),
|
money_filter(tax, self.invoice.event.currency),
|
||||||
@@ -1072,7 +1061,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
table.setStyle(TableStyle(tstyledata))
|
table.setStyle(TableStyle(tstyledata))
|
||||||
story.append(Spacer(5 * mm, 5 * mm))
|
story.append(Spacer(5 * mm, 5 * mm))
|
||||||
story.append(KeepTogether([
|
story.append(KeepTogether([
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']),
|
PlainTextParagraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']),
|
||||||
table
|
table
|
||||||
]))
|
]))
|
||||||
|
|
||||||
@@ -1089,7 +1078,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
net = gross - tax
|
net = gross - tax
|
||||||
|
|
||||||
tdata.append([
|
tdata.append([
|
||||||
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
|
PlainTextParagraph(localize(rate) + " % " + name, self.stylesheet['Fineprint']),
|
||||||
fmt(net), fmt(gross), fmt(tax), ''
|
fmt(net), fmt(gross), fmt(tax), ''
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1098,13 +1087,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
|
|
||||||
story.append(KeepTogether([
|
story.append(KeepTogether([
|
||||||
Spacer(1, height=2 * mm),
|
Spacer(1, height=2 * mm),
|
||||||
FontFallbackParagraph(
|
PlainTextParagraph(
|
||||||
self._normalize(pgettext(
|
pgettext(
|
||||||
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
|
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
|
||||||
'{date}, this corresponds to:'
|
'{date}, this corresponds to:'
|
||||||
).format(rate=localize(self.invoice.foreign_currency_rate),
|
).format(rate=localize(self.invoice.foreign_currency_rate),
|
||||||
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
|
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
|
||||||
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"))),
|
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT")),
|
||||||
self.stylesheet['Fineprint']
|
self.stylesheet['Fineprint']
|
||||||
),
|
),
|
||||||
Spacer(1, height=3 * mm),
|
Spacer(1, height=3 * mm),
|
||||||
@@ -1113,14 +1102,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
|
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
|
||||||
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
|
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
|
||||||
story.append(Spacer(1, 5 * mm))
|
story.append(Spacer(1, 5 * mm))
|
||||||
story.append(FontFallbackParagraph(self._normalize(
|
story.append(PlainTextParagraph(
|
||||||
pgettext(
|
pgettext(
|
||||||
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
|
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
|
||||||
'{date}, the invoice total corresponds to {total}.'
|
'{date}, the invoice total corresponds to {total}.'
|
||||||
).format(rate=localize(self.invoice.foreign_currency_rate),
|
).format(rate=localize(self.invoice.foreign_currency_rate),
|
||||||
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"),
|
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"),
|
||||||
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
|
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
|
||||||
total=fmt(foreign_total))),
|
total=fmt(foreign_total)),
|
||||||
self.stylesheet['Fineprint']
|
self.stylesheet['Fineprint']
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -1162,11 +1151,8 @@ class Modern1Renderer(ClassicInvoiceRenderer):
|
|||||||
def _draw_invoice_from(self, canvas):
|
def _draw_invoice_from(self, canvas):
|
||||||
if not self.invoice.address_invoice_from:
|
if not self.invoice.address_invoice_from:
|
||||||
return
|
return
|
||||||
c = [
|
c = self.invoice.address_invoice_from.strip().split('\n')
|
||||||
self._clean_text(l)
|
p = PlainTextParagraph(' · '.join(c), style=self.stylesheet['Sender'])
|
||||||
for l in self.invoice.address_invoice_from.strip().split('\n')
|
|
||||||
]
|
|
||||||
p = FontFallbackParagraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender'])
|
|
||||||
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
|
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
|
||||||
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
|
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
|
||||||
super()._draw_invoice_from(canvas)
|
super()._draw_invoice_from(canvas)
|
||||||
@@ -1225,8 +1211,8 @@ class Modern1Renderer(ClassicInvoiceRenderer):
|
|||||||
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs)
|
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs)
|
||||||
]
|
]
|
||||||
|
|
||||||
p = FontFallbackParagraph(
|
p = PlainTextParagraph(
|
||||||
self._normalize(date_format(self.invoice.date, "DATE_FORMAT")),
|
date_format(self.invoice.date, "DATE_FORMAT"),
|
||||||
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
|
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
|
||||||
)
|
)
|
||||||
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
|
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
|
||||||
@@ -1283,7 +1269,7 @@ class Modern1SimplifiedRenderer(Modern1Renderer):
|
|||||||
i = []
|
i = []
|
||||||
|
|
||||||
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
|
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
|
||||||
i.append(FontFallbackParagraph(
|
i.append(PlainTextParagraph(
|
||||||
pgettext('invoice', 'Event date: {date_range}').format(
|
pgettext('invoice', 'Event date: {date_range}').format(
|
||||||
date_range=self.invoice.event.get_date_range_display(),
|
date_range=self.invoice.event.get_date_range_display(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Do nothing. Useful for startup performance testing."
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
pass
|
||||||
@@ -40,6 +40,7 @@ from django.core.cache import cache
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import close_old_connections
|
from django.db import close_old_connections
|
||||||
from django.dispatch.dispatcher import NO_RECEIVERS
|
from django.dispatch.dispatcher import NO_RECEIVERS
|
||||||
|
from django_querytagger.tagging import with_tag
|
||||||
|
|
||||||
from pretix.helpers.periodic import SKIPPED
|
from pretix.helpers.periodic import SKIPPED
|
||||||
|
|
||||||
@@ -82,7 +83,8 @@ class Command(BaseCommand):
|
|||||||
try:
|
try:
|
||||||
# Check if the DB connection is still good, it might be closed if the previous task took too long.
|
# Check if the DB connection is still good, it might be closed if the previous task took too long.
|
||||||
close_old_connections()
|
close_old_connections()
|
||||||
r = receiver(signal=periodic_task, sender=self)
|
with with_tag(f"periodictask={name}"):
|
||||||
|
r = receiver(signal=periodic_task, sender=self)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
if isinstance(err, KeyboardInterrupt):
|
if isinstance(err, KeyboardInterrupt):
|
||||||
raise err
|
raise err
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#
|
||||||
|
# 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)
|
||||||
+20
-14
@@ -26,6 +26,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
class BaseMediaType:
|
class BaseMediaType:
|
||||||
medium_created_by_server = False
|
medium_created_by_server = False
|
||||||
|
medium_created_from_unknown_supported = False
|
||||||
supports_orderposition = False
|
supports_orderposition = False
|
||||||
supports_giftcard = False
|
supports_giftcard = False
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ class BaseMediaType:
|
|||||||
def is_active(self, organizer):
|
def is_active(self, organizer):
|
||||||
return organizer.settings.get(f'reusable_media_type_{self.identifier}', as_type=bool, default=False)
|
return organizer.settings.get(f'reusable_media_type_{self.identifier}', as_type=bool, default=False)
|
||||||
|
|
||||||
def handle_unknown(self, organizer, identifier, user, auth):
|
def handle_unknown(self, organizer, identifier, user, auth, force_create=False):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def handle_new(self, organizer, medium, user, auth):
|
def handle_new(self, organizer, medium, user, auth):
|
||||||
@@ -88,23 +89,32 @@ class NfcUidMediaType(BaseMediaType):
|
|||||||
verbose_name = _('NFC UID-based')
|
verbose_name = _('NFC UID-based')
|
||||||
icon = 'pretixbase/img/media/nfc_uid.svg'
|
icon = 'pretixbase/img/media/nfc_uid.svg'
|
||||||
medium_created_by_server = False
|
medium_created_by_server = False
|
||||||
|
medium_created_from_unknown_supported = True
|
||||||
supports_giftcard = True
|
supports_giftcard = True
|
||||||
supports_orderposition = False
|
supports_orderposition = True
|
||||||
|
|
||||||
def handle_unknown(self, organizer, identifier, user, auth):
|
def handle_unknown(self, organizer, identifier, user, auth, force_create=False):
|
||||||
from pretix.base.models import GiftCard, ReusableMedium
|
from pretix.base.models import GiftCard, ReusableMedium
|
||||||
|
|
||||||
if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool):
|
create_giftcard = organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool)
|
||||||
|
if create_giftcard or force_create:
|
||||||
if identifier.startswith("08"):
|
if identifier.startswith("08"):
|
||||||
# Don't create gift cards for NFC UIDs that start with 08, which represents NFC cards that issue random
|
# Don't create gift cards for NFC UIDs that start with 08, which represents NFC cards that issue random
|
||||||
# UIDs on every read, so they won't be useful.
|
# UIDs on every read, so they won't be useful.
|
||||||
return
|
return
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
gc = GiftCard.objects.create(
|
if create_giftcard:
|
||||||
issuer=organizer,
|
gc = GiftCard.objects.create(
|
||||||
expires=organizer.default_gift_card_expiry,
|
issuer=organizer,
|
||||||
currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'),
|
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
|
||||||
m = ReusableMedium.objects.create(
|
m = ReusableMedium.objects.create(
|
||||||
type=self.identifier,
|
type=self.identifier,
|
||||||
identifier=identifier,
|
identifier=identifier,
|
||||||
@@ -116,10 +126,6 @@ class NfcUidMediaType(BaseMediaType):
|
|||||||
'pretix.reusable_medium.created.auto',
|
'pretix.reusable_medium.created.auto',
|
||||||
user=user, auth=auth,
|
user=user, auth=auth,
|
||||||
)
|
)
|
||||||
gc.log_action(
|
|
||||||
'pretix.giftcards.created',
|
|
||||||
user=user, auth=auth,
|
|
||||||
)
|
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
@@ -129,7 +135,7 @@ class NfcMf0aesMediaType(BaseMediaType):
|
|||||||
icon = 'pretixbase/img/media/nfc_secure.svg'
|
icon = 'pretixbase/img/media/nfc_secure.svg'
|
||||||
medium_created_by_server = False
|
medium_created_by_server = False
|
||||||
supports_giftcard = True
|
supports_giftcard = True
|
||||||
supports_orderposition = False
|
supports_orderposition = True
|
||||||
|
|
||||||
def handle_new(self, organizer, medium, user, auth):
|
def handle_new(self, organizer, medium, user, auth):
|
||||||
from pretix.base.models import GiftCard
|
from pretix.base.models import GiftCard
|
||||||
|
|||||||
@@ -282,10 +282,12 @@ def metric_values():
|
|||||||
|
|
||||||
# Throwaway metrics
|
# Throwaway metrics
|
||||||
exact_tables = [
|
exact_tables = [
|
||||||
Order, OrderPosition, Invoice, Event, Organizer
|
Order, Invoice, Event, Organizer
|
||||||
]
|
]
|
||||||
for m in apps.get_models(): # Count all models
|
for m in apps.get_models(): # Count all models
|
||||||
if any(issubclass(m, p) for p in exact_tables):
|
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):
|
||||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count()
|
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count()
|
||||||
else:
|
else:
|
||||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = estimate_count_fast(m)
|
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = estimate_count_fast(m)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from urllib.parse import urlparse, urlsplit
|
from urllib.parse import urlparse, urlsplit
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
@@ -43,6 +45,8 @@ from pretix.multidomain.urlreverse import (
|
|||||||
)
|
)
|
||||||
from pretix.presale.style import get_fonts
|
from pretix.presale.style import get_fonts
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_supported = None
|
_supported = None
|
||||||
|
|
||||||
|
|
||||||
@@ -74,6 +78,7 @@ class LocaleMiddleware(MiddlewareMixin):
|
|||||||
|
|
||||||
def process_request(self, request: HttpRequest):
|
def process_request(self, request: HttpRequest):
|
||||||
language = get_language_from_request(request)
|
language = get_language_from_request(request)
|
||||||
|
region = None
|
||||||
# Normally, this middleware runs *before* the event is set. However, on event frontend pages it
|
# Normally, this middleware runs *before* the event is set. However, on event frontend pages it
|
||||||
# might be run a second time by pretix.presale.EventMiddleware and in this case the event is already
|
# might be run a second time by pretix.presale.EventMiddleware and in this case the event is already
|
||||||
# set and can be taken into account for the decision.
|
# set and can be taken into account for the decision.
|
||||||
@@ -94,15 +99,16 @@ class LocaleMiddleware(MiddlewareMixin):
|
|||||||
if '-' not in language and settings_holder.settings.region:
|
if '-' not in language and settings_holder.settings.region:
|
||||||
language += '-' + settings_holder.settings.region
|
language += '-' + settings_holder.settings.region
|
||||||
if settings_holder.settings.region:
|
if settings_holder.settings.region:
|
||||||
set_region(settings_holder.settings.region)
|
region = settings_holder.settings.region
|
||||||
else:
|
else:
|
||||||
gs = global_settings_object(request)
|
gs = global_settings_object(request)
|
||||||
if '-' not in language and gs.settings.region:
|
if '-' not in language and gs.settings.region:
|
||||||
language += '-' + gs.settings.region
|
language += '-' + gs.settings.region
|
||||||
if gs.settings.region:
|
if gs.settings.region:
|
||||||
set_region(gs.settings.region)
|
region = gs.settings.region
|
||||||
|
|
||||||
translation.activate(language)
|
translation.activate(language)
|
||||||
|
set_region(region)
|
||||||
request.LANGUAGE_CODE = get_language_without_region()
|
request.LANGUAGE_CODE = get_language_without_region()
|
||||||
|
|
||||||
tzname = None
|
tzname = None
|
||||||
@@ -221,7 +227,26 @@ def _parse_csp(header):
|
|||||||
return h
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
VALID_CSP_DIRECTIVES = [
|
||||||
|
"child-src", "connect-src", "default-src", "fenced-frame-src", "font-src", "form-action", "frame-src", "img-src",
|
||||||
|
"manifest-src", "media-src", "object-src", "prefetch-src", "report-uri", "script-src", "script-src-elem",
|
||||||
|
"script-src-attr", "style-src", "style-src-elem", "style-src-attr", "worker-src",
|
||||||
|
]
|
||||||
|
|
||||||
|
CSP_ILLEGAL_CHARS = re.compile(r'[\s,;]')
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_csp(h):
|
||||||
|
for k, v in h.items():
|
||||||
|
if k not in VALID_CSP_DIRECTIVES:
|
||||||
|
raise ValueError("Invalid CSP directive " + k)
|
||||||
|
if any(CSP_ILLEGAL_CHARS.search(el) for el in v):
|
||||||
|
logger.warning("Stripping invalid component from CSP: %r", h)
|
||||||
|
h[k] = [el for el in v if not CSP_ILLEGAL_CHARS.search(el)]
|
||||||
|
|
||||||
|
|
||||||
def _render_csp(h):
|
def _render_csp(h):
|
||||||
|
_sanitize_csp(h)
|
||||||
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v)
|
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v)
|
||||||
|
|
||||||
|
|
||||||
@@ -241,21 +266,7 @@ def _merge_csp(a, b):
|
|||||||
|
|
||||||
|
|
||||||
class SecurityMiddleware(MiddlewareMixin):
|
class SecurityMiddleware(MiddlewareMixin):
|
||||||
CSP_EXEMPT = (
|
|
||||||
'/api/v1/docs/',
|
|
||||||
)
|
|
||||||
|
|
||||||
def process_response(self, request, resp):
|
def process_response(self, request, resp):
|
||||||
def nested_dict_values(d):
|
|
||||||
for v in d.values():
|
|
||||||
if isinstance(v, dict):
|
|
||||||
yield from nested_dict_values(v)
|
|
||||||
else:
|
|
||||||
if isinstance(v, str):
|
|
||||||
yield v
|
|
||||||
|
|
||||||
url = resolve(request.path_info)
|
|
||||||
|
|
||||||
if settings.DEBUG and resp.status_code >= 400:
|
if settings.DEBUG and resp.status_code >= 400:
|
||||||
# Don't use CSP on debug error page as it breaks of Django's fancy error
|
# Don't use CSP on debug error page as it breaks of Django's fancy error
|
||||||
# pages
|
# pages
|
||||||
@@ -266,28 +277,25 @@ class SecurityMiddleware(MiddlewareMixin):
|
|||||||
# https://github.com/pretix/pretix/issues/765
|
# https://github.com/pretix/pretix/issues/765
|
||||||
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
|
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
|
||||||
|
|
||||||
img_src = []
|
if not getattr(resp, '_csp_ignore', False):
|
||||||
gs = global_settings_object(request)
|
resp['Content-Security-Policy'] = _render_csp(self._build_csp(request, resp))
|
||||||
if gs.settings.leaflet_tiles:
|
elif 'Content-Security-Policy' in resp:
|
||||||
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
|
del resp['Content-Security-Policy']
|
||||||
|
|
||||||
font_src = set()
|
return resp
|
||||||
if hasattr(request, 'event'):
|
|
||||||
for font in get_fonts(request.event, pdf_support_required=False).values():
|
def _build_csp(self, request, resp):
|
||||||
for path in list(nested_dict_values(font)):
|
url = resolve(request.path_info)
|
||||||
font_location = urlparse(path)
|
|
||||||
if font_location.scheme and font_location.netloc:
|
|
||||||
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
|
|
||||||
|
|
||||||
h = {
|
h = {
|
||||||
'default-src': ["{static}"],
|
'default-src': ["{static}"],
|
||||||
'script-src': ['{static}'],
|
'script-src': ["{static}"],
|
||||||
'object-src': ["'none'"],
|
'object-src': ["'none'"],
|
||||||
'frame-src': ['{static}'],
|
'frame-src': ['{static}'],
|
||||||
'style-src': ["{static}", "{media}"],
|
'style-src': ["{static}", "{media}"],
|
||||||
'connect-src': ["{dynamic}", "{media}"],
|
'connect-src': ["{dynamic}", "{media}"],
|
||||||
'img-src': ["{static}", "{media}", "data:"] + img_src,
|
'img-src': ["{static}", "{media}", "data:"],
|
||||||
'font-src': ["{static}"] + list(font_src),
|
'font-src': ["{static}"],
|
||||||
'media-src': ["{static}", "data:"],
|
'media-src': ["{static}", "data:"],
|
||||||
# form-action is not only used to match on form actions, but also on URLs
|
# form-action is not only used to match on form actions, but also on URLs
|
||||||
# form-actions redirect to. In the context of e.g. payment providers or
|
# form-actions redirect to. In the context of e.g. payment providers or
|
||||||
@@ -295,6 +303,26 @@ class SecurityMiddleware(MiddlewareMixin):
|
|||||||
# this. However, we'll restrict it to HTTPS.
|
# this. However, we'll restrict it to HTTPS.
|
||||||
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
|
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gs = global_settings_object(request)
|
||||||
|
if gs.settings.leaflet_tiles:
|
||||||
|
h['img-src'].append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
|
||||||
|
|
||||||
|
if hasattr(request, 'event'):
|
||||||
|
h['font-src'] += list(self._get_font_origins(request.event))
|
||||||
|
|
||||||
|
if settings.VITE_DEV_MODE:
|
||||||
|
h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"]
|
||||||
|
h['style-src'] += ["'unsafe-inline'"]
|
||||||
|
h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"]
|
||||||
|
|
||||||
|
if hasattr(request, 'csp_nonce'):
|
||||||
|
nonce = f"'nonce-{request.csp_nonce}'"
|
||||||
|
h['script-src'].append(nonce)
|
||||||
|
if not settings.VITE_DEV_MODE:
|
||||||
|
# can't have 'unsafe-inline' and nonce at the same time
|
||||||
|
h['style-src'].append(nonce)
|
||||||
|
|
||||||
# Only include pay.google.com for wallet detection purposes on the Payment selection page
|
# Only include pay.google.com for wallet detection purposes on the Payment selection page
|
||||||
if (
|
if (
|
||||||
url.url_name == "event.order.pay.change" or
|
url.url_name == "event.order.pay.change" or
|
||||||
@@ -303,27 +331,32 @@ class SecurityMiddleware(MiddlewareMixin):
|
|||||||
h['script-src'].append('https://pay.google.com')
|
h['script-src'].append('https://pay.google.com')
|
||||||
h['frame-src'].append('https://pay.google.com')
|
h['frame-src'].append('https://pay.google.com')
|
||||||
h['connect-src'].append('https://google.com/pay')
|
h['connect-src'].append('https://google.com/pay')
|
||||||
|
|
||||||
if settings.LOG_CSP:
|
if settings.LOG_CSP:
|
||||||
h['report-uri'] = ["/csp_report/"]
|
h['report-uri'] = ["/csp_report/"]
|
||||||
|
|
||||||
if 'Content-Security-Policy' in resp:
|
if 'Content-Security-Policy' in resp:
|
||||||
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
|
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
|
||||||
|
|
||||||
if settings.CSP_ADDITIONAL_HEADER:
|
if settings.CSP_ADDITIONAL_HEADER:
|
||||||
_merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER))
|
_merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER))
|
||||||
|
|
||||||
staticdomain = "'self'"
|
placeholders = {
|
||||||
dynamicdomain = "'self'"
|
"{static}": ["'self'"],
|
||||||
mediadomain = "'self'"
|
"{dynamic}": ["'self'"],
|
||||||
|
"{media}": ["'self'"],
|
||||||
|
}
|
||||||
if settings.MEDIA_URL.startswith('http'):
|
if settings.MEDIA_URL.startswith('http'):
|
||||||
mediadomain += " " + settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)]
|
placeholders["{media}"].append(settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)])
|
||||||
if settings.STATIC_URL.startswith('http'):
|
if settings.STATIC_URL.startswith('http'):
|
||||||
staticdomain += " " + settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)]
|
placeholders["{static}"].append(settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)])
|
||||||
if settings.SITE_URL.startswith('http'):
|
if settings.SITE_URL.startswith('http'):
|
||||||
if settings.SITE_URL.find('/', 9) > 0:
|
if settings.SITE_URL.find('/', 9) > 0:
|
||||||
staticdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
|
placeholders["{static}"].append(settings.SITE_URL[:settings.SITE_URL.find('/', 9)])
|
||||||
dynamicdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
|
placeholders["{dynamic}"].append(settings.SITE_URL[:settings.SITE_URL.find('/', 9)])
|
||||||
else:
|
else:
|
||||||
staticdomain += " " + settings.SITE_URL
|
placeholders["{static}"].append(settings.SITE_URL)
|
||||||
dynamicdomain += " " + settings.SITE_URL
|
placeholders["{dynamic}"].append(settings.SITE_URL)
|
||||||
|
|
||||||
if hasattr(request, 'organizer') and request.organizer:
|
if hasattr(request, 'organizer') and request.organizer:
|
||||||
if hasattr(request, 'event') and request.event:
|
if hasattr(request, 'event') and request.event:
|
||||||
@@ -334,18 +367,29 @@ class SecurityMiddleware(MiddlewareMixin):
|
|||||||
siteurlsplit = urlsplit(settings.SITE_URL)
|
siteurlsplit = urlsplit(settings.SITE_URL)
|
||||||
if siteurlsplit.port and siteurlsplit.port not in (80, 443):
|
if siteurlsplit.port and siteurlsplit.port not in (80, 443):
|
||||||
domain = '%s:%d' % (domain, siteurlsplit.port)
|
domain = '%s:%d' % (domain, siteurlsplit.port)
|
||||||
dynamicdomain += " " + domain
|
placeholders["{dynamic}"].append(domain)
|
||||||
|
|
||||||
if request.path not in self.CSP_EXEMPT and not getattr(resp, '_csp_ignore', False):
|
for k, v in h.items():
|
||||||
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain,
|
h[k] = sorted(set(result for part in v for result in placeholders.get(part, [part])))
|
||||||
media=mediadomain)
|
|
||||||
for k, v in h.items():
|
|
||||||
h[k] = sorted(set(' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' ')))
|
|
||||||
resp['Content-Security-Policy'] = _render_csp(h)
|
|
||||||
elif 'Content-Security-Policy' in resp:
|
|
||||||
del resp['Content-Security-Policy']
|
|
||||||
|
|
||||||
return resp
|
return h
|
||||||
|
|
||||||
|
def _get_font_origins(self, event):
|
||||||
|
def nested_dict_values(d):
|
||||||
|
for v in d.values():
|
||||||
|
if isinstance(v, dict):
|
||||||
|
yield from nested_dict_values(v)
|
||||||
|
else:
|
||||||
|
if isinstance(v, str):
|
||||||
|
yield v
|
||||||
|
|
||||||
|
font_src = set()
|
||||||
|
for font in get_fonts(event, pdf_support_required=False).values():
|
||||||
|
for path in list(nested_dict_values(font)):
|
||||||
|
font_location = urlparse(path)
|
||||||
|
if font_location.scheme and font_location.netloc:
|
||||||
|
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
|
||||||
|
return font_src
|
||||||
|
|
||||||
|
|
||||||
class RejectInvalidInputMiddleware(MiddlewareMixin):
|
class RejectInvalidInputMiddleware(MiddlewareMixin):
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 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;",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -442,7 +442,7 @@ class AttendeeState(ImportColumn):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def verbose_name(self):
|
def verbose_name(self):
|
||||||
return _('Attendee address') + ': ' + _('State')
|
return _('Attendee address') + ': ' + pgettext('address', 'State')
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
def clean(self, value, previous_values):
|
||||||
if value:
|
if value:
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ from django_otp.models import Device
|
|||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.helpers.urls import build_absolute_uri
|
from pretix.helpers.urls import mainreverse_absolute
|
||||||
|
|
||||||
from ...helpers.countries import FastCountryField
|
from ...helpers.countries import FastCountryField
|
||||||
from ...helpers.u2f import pub_key_from_der, websafe_decode
|
from ...helpers.u2f import pub_key_from_der, websafe_decode
|
||||||
@@ -373,12 +373,12 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
|
|
||||||
mail(
|
mail(
|
||||||
email or self.email,
|
email or self.email,
|
||||||
_('Account information changed'),
|
_('Changes to your account'),
|
||||||
'pretixcontrol/email/security_notice.txt',
|
'pretixcontrol/email/security_notice.txt',
|
||||||
{
|
{
|
||||||
'user': self,
|
'user': self,
|
||||||
'messages': msg,
|
'messages': msg,
|
||||||
'url': build_absolute_uri('control:user.settings'),
|
'url': mainreverse_absolute('control:user.settings'),
|
||||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||||
},
|
},
|
||||||
event=None,
|
event=None,
|
||||||
@@ -400,12 +400,13 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
|
|
||||||
with language(self.locale):
|
with language(self.locale):
|
||||||
if reason == 'email_change':
|
if reason == 'email_change':
|
||||||
msg = str(_('to confirm changing your email address from {old_email}\nto {new_email}, use the following code:').format(
|
msg = str(_('To change your email address from {old_email} to {new_email}, use the following code:').format(
|
||||||
old_email=self.email, new_email=email,
|
old_email=self.email, new_email=email,
|
||||||
))
|
))
|
||||||
elif reason == 'email_verify':
|
elif reason == 'email_verify':
|
||||||
msg = str(_('to confirm that your email address {email} belongs to your pretix account, use the following code:').format(
|
msg = str(_('To verify your email address {email} on {instance}, use the following code:').format(
|
||||||
email=self.email,
|
email=self.email,
|
||||||
|
instance=settings.PRETIX_INSTANCE_NAME,
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
raise Exception('Invalid confirmation code reason')
|
raise Exception('Invalid confirmation code reason')
|
||||||
@@ -418,7 +419,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
}
|
}
|
||||||
mail(
|
mail(
|
||||||
email or self.email,
|
email or self.email,
|
||||||
_('pretix confirmation code'),
|
_('Your confirmation code'),
|
||||||
'pretixcontrol/email/confirmation_code.txt',
|
'pretixcontrol/email/confirmation_code.txt',
|
||||||
{
|
{
|
||||||
'user': self,
|
'user': self,
|
||||||
@@ -462,11 +463,13 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
from pretix.base.services.mail import mail
|
from pretix.base.services.mail import mail
|
||||||
|
|
||||||
mail(
|
mail(
|
||||||
self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
|
self.email,
|
||||||
|
_('Reset your password'),
|
||||||
|
'pretixcontrol/email/forgot.txt',
|
||||||
{
|
{
|
||||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||||
'user': self,
|
'user': self,
|
||||||
'url': (build_absolute_uri('control:auth.forgot.recover')
|
'url': (mainreverse_absolute('control:auth.forgot.recover')
|
||||||
+ '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self)))
|
+ '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self)))
|
||||||
},
|
},
|
||||||
None, locale=self.locale, user=self
|
None, locale=self.locale, user=self
|
||||||
@@ -647,25 +650,22 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
id__in=self.teams.filter(TeamQuerySet.organizer_permission_q(permission)).values_list('organizer', flat=True)
|
id__in=self.teams.filter(TeamQuerySet.organizer_permission_q(permission)).values_list('organizer', flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
def has_active_staff_session(self, session_key=None):
|
def has_active_staff_session(self, session_key):
|
||||||
"""
|
"""
|
||||||
Returns whether or not a user has an active staff session (formerly known as superuser session)
|
Returns whether or not a user has an active staff session (formerly known as superuser session)
|
||||||
with the given session key.
|
with the given session key.
|
||||||
"""
|
"""
|
||||||
return self.get_active_staff_session(session_key) is not None
|
return self.get_active_staff_session(session_key) is not None
|
||||||
|
|
||||||
def get_active_staff_session(self, session_key=None):
|
def get_active_staff_session(self, session_key):
|
||||||
if not self.is_staff:
|
if not self.is_staff or not session_key:
|
||||||
return None
|
return None
|
||||||
if not hasattr(self, '_staff_session_cache'):
|
if not hasattr(self, '_staff_session_cache'):
|
||||||
self._staff_session_cache = {}
|
self._staff_session_cache = {}
|
||||||
if session_key not in self._staff_session_cache:
|
if session_key not in self._staff_session_cache:
|
||||||
qs = StaffSession.objects.filter(
|
sess = StaffSession.objects.filter(
|
||||||
user=self, date_end__isnull=True
|
user=self, date_end__isnull=True, session_key=session_key
|
||||||
)
|
).first()
|
||||||
if session_key:
|
|
||||||
qs = qs.filter(session_key=session_key)
|
|
||||||
sess = qs.first()
|
|
||||||
if sess:
|
if sess:
|
||||||
if sess.date_start < now() - timedelta(seconds=settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE):
|
if sess.date_start < now() - timedelta(seconds=settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE):
|
||||||
sess.date_end = now()
|
sess.date_end = now()
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class LoggingMixin:
|
|||||||
elif isinstance(self, Event):
|
elif isinstance(self, Event):
|
||||||
event = self
|
event = self
|
||||||
organizer_id = self.organizer_id
|
organizer_id = self.organizer_id
|
||||||
elif hasattr(self, 'event'):
|
elif hasattr(self, 'event') and self.event:
|
||||||
event = self.event
|
event = self.event
|
||||||
organizer_id = self.event.organizer_id
|
organizer_id = self.event.organizer_id
|
||||||
elif hasattr(self, 'organizer_id'):
|
elif hasattr(self, 'organizer_id'):
|
||||||
|
|||||||
@@ -346,11 +346,14 @@ class Checkin(models.Model):
|
|||||||
REASON_INCOMPLETE = 'incomplete'
|
REASON_INCOMPLETE = 'incomplete'
|
||||||
REASON_ALREADY_REDEEMED = 'already_redeemed'
|
REASON_ALREADY_REDEEMED = 'already_redeemed'
|
||||||
REASON_AMBIGUOUS = 'ambiguous'
|
REASON_AMBIGUOUS = 'ambiguous'
|
||||||
|
REASON_MEDIUM_INVALID = 'medium_invalid'
|
||||||
|
REASON_MEDIUM_EXISTS = 'medium_exists'
|
||||||
REASON_ERROR = 'error'
|
REASON_ERROR = 'error'
|
||||||
REASON_BLOCKED = 'blocked'
|
REASON_BLOCKED = 'blocked'
|
||||||
REASON_UNAPPROVED = 'unapproved'
|
REASON_UNAPPROVED = 'unapproved'
|
||||||
REASON_INVALID_TIME = 'invalid_time'
|
REASON_INVALID_TIME = 'invalid_time'
|
||||||
REASON_ANNULLED = 'annulled'
|
REASON_ANNULLED = 'annulled'
|
||||||
|
REASON_ALREADY_EXCHANGED = 'already_exchanged'
|
||||||
REASONS = (
|
REASONS = (
|
||||||
(REASON_CANCELED, _('Order canceled')),
|
(REASON_CANCELED, _('Order canceled')),
|
||||||
(REASON_INVALID, _('Unknown ticket')),
|
(REASON_INVALID, _('Unknown ticket')),
|
||||||
@@ -366,6 +369,9 @@ class Checkin(models.Model):
|
|||||||
(REASON_UNAPPROVED, _('Order not approved')),
|
(REASON_UNAPPROVED, _('Order not approved')),
|
||||||
(REASON_INVALID_TIME, _('Ticket not valid at this time')),
|
(REASON_INVALID_TIME, _('Ticket not valid at this time')),
|
||||||
(REASON_ANNULLED, _('Check-in annulled')),
|
(REASON_ANNULLED, _('Check-in annulled')),
|
||||||
|
(REASON_ALREADY_EXCHANGED, _('Ticket already exchanged')),
|
||||||
|
(REASON_MEDIUM_INVALID, _('Reusable medium invalid')),
|
||||||
|
(REASON_MEDIUM_EXISTS, _('Reusable medium already exists')),
|
||||||
)
|
)
|
||||||
|
|
||||||
successful = models.BooleanField(
|
successful = models.BooleanField(
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ class Customer(LoggedModel):
|
|||||||
|
|
||||||
def send_security_notice(self, message, email=None):
|
def send_security_notice(self, message, email=None):
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import SendMailException, mail
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with language(self.locale):
|
with language(self.locale):
|
||||||
@@ -178,7 +178,7 @@ class Customer(LoggedModel):
|
|||||||
{
|
{
|
||||||
**self.get_email_context(),
|
**self.get_email_context(),
|
||||||
'message': str(message),
|
'message': str(message),
|
||||||
'url': build_absolute_uri(self.organizer, 'presale:organizer.customer.index')
|
'url': eventreverse_absolute(self.organizer, 'presale:organizer.customer.index')
|
||||||
},
|
},
|
||||||
customer=self,
|
customer=self,
|
||||||
organizer=self.organizer,
|
organizer=self.organizer,
|
||||||
@@ -299,12 +299,12 @@ class Customer(LoggedModel):
|
|||||||
|
|
||||||
def send_activation_mail(self):
|
def send_activation_mail(self):
|
||||||
from pretix.base.services.mail import mail
|
from pretix.base.services.mail import mail
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||||
from pretix.presale.forms.customer import TokenGenerator
|
from pretix.presale.forms.customer import TokenGenerator
|
||||||
|
|
||||||
ctx = self.get_email_context()
|
ctx = self.get_email_context()
|
||||||
token = TokenGenerator().make_token(self)
|
token = TokenGenerator().make_token(self)
|
||||||
ctx['url'] = build_absolute_uri(
|
ctx['url'] = eventreverse_absolute(
|
||||||
self.organizer,
|
self.organizer,
|
||||||
'presale:organizer.customer.activate'
|
'presale:organizer.customer.activate'
|
||||||
) + '?id=' + self.identifier + '&token=' + token
|
) + '?id=' + self.identifier + '&token=' + token
|
||||||
@@ -395,7 +395,7 @@ class AttendeeProfile(models.Model):
|
|||||||
self.company,
|
self.company,
|
||||||
self.street,
|
self.street,
|
||||||
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||||
self.country.name,
|
self.country.name if self.country else None,
|
||||||
]
|
]
|
||||||
for a in self.answers:
|
for a in self.answers:
|
||||||
value = a.get('value')
|
value = a.get('value')
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import warnings
|
|||||||
from collections import Counter, OrderedDict, defaultdict
|
from collections import Counter, OrderedDict, defaultdict
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
from operator import attrgetter
|
from operator import attrgetter
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
@@ -79,10 +80,16 @@ from pretix.helpers.thumb import get_thumbnail
|
|||||||
from ..settings import settings_hierarkey
|
from ..settings import settings_hierarkey
|
||||||
from .organizer import Organizer, Team
|
from .organizer import Organizer, Team
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from hierarkey.proxy import HierarkeyProxy
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class EventMixin:
|
class EventMixin:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
settings: HierarkeyProxy
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
|
if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
|
||||||
raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')})
|
raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')})
|
||||||
@@ -724,7 +731,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def social_image(self):
|
def social_image(self):
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||||
|
|
||||||
img = None
|
img = None
|
||||||
logo_file = self.settings.get('logo_image', as_type=str, default='')[7:]
|
logo_file = self.settings.get('logo_image', as_type=str, default='')[7:]
|
||||||
@@ -742,7 +749,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
logger.exception(f'Failed to create thumbnail of {logo_file}')
|
logger.exception(f'Failed to create thumbnail of {logo_file}')
|
||||||
img = default_storage.url(logo_file)
|
img = default_storage.url(logo_file)
|
||||||
if img:
|
if img:
|
||||||
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
|
return urljoin(eventreverse_absolute(self, 'presale:event.index'), img)
|
||||||
|
|
||||||
def _seats(self, ignore_voucher=None):
|
def _seats(self, ignore_voucher=None):
|
||||||
from .seating import Seat
|
from .seating import Seat
|
||||||
@@ -883,6 +890,8 @@ class Event(EventMixin, LoggedModel):
|
|||||||
ItemProgramTime, ItemVariationMetaValue, Question, Quota,
|
ItemProgramTime, ItemVariationMetaValue, Question, Quota,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_cross_organizer = other.organizer_id != self.organizer_id
|
||||||
|
|
||||||
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
|
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
|
||||||
# Plugins can create data in installed() hook based on existing data of the event.
|
# Plugins can create data in installed() hook based on existing data of the event.
|
||||||
# Calling set_active_plugins() results in defaults being created while actually data
|
# Calling set_active_plugins() results in defaults being created while actually data
|
||||||
@@ -897,7 +906,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
self.save()
|
self.save()
|
||||||
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
|
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
|
||||||
|
|
||||||
if hasattr(other, 'alternative_domain_assignment'):
|
if hasattr(other, 'alternative_domain_assignment') and not is_cross_organizer:
|
||||||
other.alternative_domain_assignment.domain.event_assignments.create(event=self)
|
other.alternative_domain_assignment.domain.event_assignments.create(event=self)
|
||||||
|
|
||||||
if not self.all_sales_channels:
|
if not self.all_sales_channels:
|
||||||
@@ -911,6 +920,15 @@ class Event(EventMixin, LoggedModel):
|
|||||||
for emv in EventMetaValue.objects.filter(event=other):
|
for emv in EventMetaValue.objects.filter(event=other):
|
||||||
emv.pk = None
|
emv.pk = None
|
||||||
emv.event = self
|
emv.event = self
|
||||||
|
if is_cross_organizer:
|
||||||
|
try:
|
||||||
|
emv.property = self.organizer.meta_properties.get(name=emv.property.name)
|
||||||
|
except EventMetaProperty.DoesNotExist:
|
||||||
|
meta_prop = emv.property
|
||||||
|
meta_prop.pk = None
|
||||||
|
meta_prop.organizer = self.organizer
|
||||||
|
meta_prop.save(force_insert=True)
|
||||||
|
emv.property = meta_prop
|
||||||
emv.save(force_insert=True)
|
emv.save(force_insert=True)
|
||||||
|
|
||||||
for fl in EventFooterLink.objects.filter(event=other):
|
for fl in EventFooterLink.objects.filter(event=other):
|
||||||
@@ -964,13 +982,13 @@ class Event(EventMixin, LoggedModel):
|
|||||||
if i.tax_rule_id:
|
if i.tax_rule_id:
|
||||||
i.tax_rule = tax_map[i.tax_rule_id]
|
i.tax_rule = tax_map[i.tax_rule_id]
|
||||||
|
|
||||||
if i.grant_membership_type and other.organizer_id != self.organizer_id:
|
if i.grant_membership_type and is_cross_organizer:
|
||||||
i.grant_membership_type = None
|
i.grant_membership_type = None
|
||||||
|
|
||||||
i.save() # no force_insert since i.picture.save could have already inserted
|
i.save() # no force_insert since i.picture.save could have already inserted
|
||||||
i.log_action('pretix.object.cloned')
|
i.log_action('pretix.object.cloned')
|
||||||
|
|
||||||
if require_membership_types and other.organizer_id == self.organizer_id:
|
if require_membership_types and not is_cross_organizer:
|
||||||
i.require_membership_types.set(require_membership_types)
|
i.require_membership_types.set(require_membership_types)
|
||||||
|
|
||||||
if not i.all_sales_channels:
|
if not i.all_sales_channels:
|
||||||
@@ -985,7 +1003,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
v._prefetched_objects_cache = {}
|
v._prefetched_objects_cache = {}
|
||||||
v.save(force_insert=True)
|
v.save(force_insert=True)
|
||||||
|
|
||||||
if require_membership_types and other.organizer_id == self.organizer_id:
|
if require_membership_types and not is_cross_organizer:
|
||||||
v.require_membership_types.set(require_membership_types)
|
v.require_membership_types.set(require_membership_types)
|
||||||
if not v.all_sales_channels:
|
if not v.all_sales_channels:
|
||||||
v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
|
v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
|
||||||
@@ -1869,6 +1887,8 @@ class EventMetaValue(LoggedModel):
|
|||||||
self.event.cache.clear()
|
self.event.cache.clear()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
if self.event and self.event.organizer != self.property.organizer:
|
||||||
|
raise ValidationError(_("Property and event must belong to the same organizer."))
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
if self.event:
|
if self.event:
|
||||||
self.event.cache.clear()
|
self.event.cache.clear()
|
||||||
|
|||||||
@@ -452,11 +452,16 @@ class Item(LoggedModel):
|
|||||||
MEDIA_POLICY_REUSE = 'reuse'
|
MEDIA_POLICY_REUSE = 'reuse'
|
||||||
MEDIA_POLICY_NEW = 'new'
|
MEDIA_POLICY_NEW = 'new'
|
||||||
MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new'
|
MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new'
|
||||||
|
MEDIA_POLICY_APPEND = 'append'
|
||||||
|
MEDIA_POLICY_APPEND_OR_NEW = 'append_or_new'
|
||||||
MEDIA_POLICIES = (
|
MEDIA_POLICIES = (
|
||||||
(None, _("Don't use re-usable media, use regular one-off tickets")),
|
(None, _("Don't use reusable media, use regular one-off tickets")),
|
||||||
(MEDIA_POLICY_REUSE, _('Require an existing medium to be re-used')),
|
|
||||||
(MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')),
|
(MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')),
|
||||||
(MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used')),
|
(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')),
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = ItemQuerySetManager()
|
objects = ItemQuerySetManager()
|
||||||
@@ -769,7 +774,7 @@ class Item(LoggedModel):
|
|||||||
null=True, blank=True, max_length=16,
|
null=True, blank=True, max_length=16,
|
||||||
verbose_name=_('Reusable media policy'),
|
verbose_name=_('Reusable media policy'),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
'If this product should be stored on a re-usable physical medium, you can attach a physical media policy. '
|
'If this product should be stored on a reusable physical medium, you can attach a physical media policy. '
|
||||||
'This is not required for regular tickets, which just use a one-time barcode, but only for products like '
|
'This is not required for regular tickets, which just use a one-time barcode, but only for products like '
|
||||||
'renewable season tickets or re-chargeable gift card wristbands. '
|
'renewable season tickets or re-chargeable gift card wristbands. '
|
||||||
'This is an advanced feature that also requires specific configuration of ticketing and printing settings.'
|
'This is an advanced feature that also requires specific configuration of ticketing and printing settings.'
|
||||||
@@ -778,7 +783,7 @@ class Item(LoggedModel):
|
|||||||
media_type = models.CharField(
|
media_type = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
choices=[(None, _("Don't use re-usable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()],
|
choices=[(None, _("Don't use reusable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()],
|
||||||
verbose_name=_('Reusable media type'),
|
verbose_name=_('Reusable media type'),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
'Select the type of physical medium that should be used for this product. Note that not all media types '
|
'Select the type of physical medium that should be used for this product. Note that not all media types '
|
||||||
@@ -995,6 +1000,11 @@ class Item(LoggedModel):
|
|||||||
raise ValidationError(_('The selected media type does not support usage for tickets currently.'))
|
raise ValidationError(_('The selected media type does not support usage for tickets currently.'))
|
||||||
if not mt.supports_giftcard and issue_giftcard:
|
if not mt.supports_giftcard and issue_giftcard:
|
||||||
raise ValidationError(_('The selected media type does not support usage for gift cards currently.'))
|
raise ValidationError(_('The selected media type does not support usage for gift cards currently.'))
|
||||||
|
if media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
||||||
|
if not mt.medium_created_by_server and not mt.medium_created_from_unknown_supported:
|
||||||
|
raise ValidationError(_('The selected media type requires all media to be registered in the system '
|
||||||
|
'prior to their usage. Therefore, the selected media policy does not make '
|
||||||
|
'sense for this media type.'))
|
||||||
if issue_giftcard:
|
if issue_giftcard:
|
||||||
raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, '
|
raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, '
|
||||||
'gift cards for some reusable media types can be created or re-charged directly '
|
'gift cards for some reusable media types can be created or re-charged directly '
|
||||||
@@ -2220,7 +2230,7 @@ class Quota(LoggedModel):
|
|||||||
class ItemMetaProperty(LoggedModel):
|
class ItemMetaProperty(LoggedModel):
|
||||||
"""
|
"""
|
||||||
An event can have ItemMetaProperty objects attached to define meta information fields
|
An event can have ItemMetaProperty objects attached to define meta information fields
|
||||||
for its items. This information can be re-used for example in ticket layouts.
|
for its items. This information can be reused for example in ticket layouts.
|
||||||
|
|
||||||
:param event: The event this property is defined for.
|
:param event: The event this property is defined for.
|
||||||
:type event: Event
|
:type event: Event
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ class ReusableMedium(LoggedModel):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
verbose_name=pgettext_lazy('reusable_medium', 'Identifier'),
|
verbose_name=pgettext_lazy('reusable_medium', 'Identifier'),
|
||||||
)
|
)
|
||||||
|
claim_token = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
verbose_name=pgettext_lazy('reusable_medium', 'Claim token'),
|
||||||
|
null=True, blank=True
|
||||||
|
)
|
||||||
|
label = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
verbose_name=pgettext_lazy('reusable_medium', 'Label'),
|
||||||
|
null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
active = models.BooleanField(
|
active = models.BooleanField(
|
||||||
verbose_name=_('Active'),
|
verbose_name=_('Active'),
|
||||||
@@ -89,12 +99,14 @@ class ReusableMedium(LoggedModel):
|
|||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
verbose_name=_('Customer account'),
|
verbose_name=_('Customer account'),
|
||||||
)
|
)
|
||||||
linked_orderposition = models.ForeignKey(
|
linked_orderpositions = models.ManyToManyField(
|
||||||
OrderPosition,
|
OrderPosition,
|
||||||
null=True, blank=True,
|
|
||||||
related_name='linked_media',
|
related_name='linked_media',
|
||||||
on_delete=models.SET_NULL,
|
verbose_name=_('Linked tickets'),
|
||||||
verbose_name=_('Linked ticket'),
|
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.'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
linked_giftcard = models.ForeignKey(
|
linked_giftcard = models.ForeignKey(
|
||||||
GiftCard,
|
GiftCard,
|
||||||
@@ -117,7 +129,10 @@ class ReusableMedium(LoggedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_expired(self):
|
def is_expired(self):
|
||||||
return self.expires and self.expires > now()
|
return self.expires and self.expires < now()
|
||||||
|
|
||||||
|
def touch(self):
|
||||||
|
self.save(update_fields=['updated'])
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (("identifier", "type", "organizer"),)
|
unique_together = (("identifier", "type", "organizer"),)
|
||||||
|
|||||||
@@ -354,38 +354,60 @@ class Order(LockModel, LoggedModel):
|
|||||||
def _transaction_key_reset(self):
|
def _transaction_key_reset(self):
|
||||||
self.__initial_status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval
|
self.__initial_status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval
|
||||||
|
|
||||||
def gracefully_delete(self, user=None, auth=None):
|
@classmethod
|
||||||
from . import GiftCard, GiftCardTransaction, Membership, Voucher
|
def gracefully_delete_bulk(cls, event, orders, user=None, auth=None):
|
||||||
|
# Expects to be called in a transaction
|
||||||
if not self.testmode:
|
from . import (
|
||||||
raise TypeError("Only test mode orders can be deleted.")
|
GiftCard, GiftCardTransaction, LogEntry, Membership, Voucher,
|
||||||
self.log_action(
|
|
||||||
'pretix.event.order.deleted', user=user, auth=auth,
|
|
||||||
data={
|
|
||||||
'code': self.code,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
order_gracefully_delete.send(self.event, order=self)
|
if not transaction.get_connection().in_atomic_block:
|
||||||
|
raise Exception('gracefully_delete_bulk should only be called in atomic transaction!')
|
||||||
|
|
||||||
if self.status != Order.STATUS_CANCELED:
|
logs_create = []
|
||||||
for position in self.positions.all():
|
for o in orders:
|
||||||
if position.voucher:
|
if not o.testmode:
|
||||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
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)
|
||||||
|
|
||||||
GiftCardTransaction.objects.filter(payment__in=self.payments.all()).update(payment=None)
|
voucher_ids = OrderPosition.objects.filter(
|
||||||
GiftCardTransaction.objects.filter(refund__in=self.refunds.all()).update(refund=None)
|
order__in=orders,
|
||||||
GiftCardTransaction.objects.filter(order=self).update(order=None)
|
voucher__isnull=False
|
||||||
GiftCard.objects.filter(issued_in__in=self.positions.all()).update(issued_in=None)
|
).exclude(order__status=Order.STATUS_CANCELED).values_list("voucher_id", flat=True)
|
||||||
Membership.objects.filter(granted_in__order=self, testmode=True).update(granted_in=None)
|
voucher_usages = Counter(voucher_ids)
|
||||||
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
|
for v_id, usage_count in voucher_usages.items():
|
||||||
OrderPosition.all.filter(order=self).delete()
|
Voucher.objects.filter(pk=v_id).update(redeemed=Greatest(0, F('redeemed') - usage_count))
|
||||||
OrderFee.all.filter(order=self).delete()
|
|
||||||
Transaction.objects.filter(order=self).delete()
|
GiftCardTransaction.objects.filter(payment__order__in=orders).update(payment=None)
|
||||||
self.refunds.all().delete()
|
GiftCardTransaction.objects.filter(refund__order__in=orders).update(refund=None)
|
||||||
self.payments.all().delete()
|
GiftCardTransaction.objects.filter(order__in=orders).update(order=None)
|
||||||
self.event.cache.delete('complain_testmode_orders')
|
GiftCard.objects.filter(issued_in__order__in=orders).update(issued_in=None)
|
||||||
self.delete()
|
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):
|
||||||
|
if not self.testmode:
|
||||||
|
raise TypeError("Only test mode orders can be deleted.")
|
||||||
|
|
||||||
|
Order.gracefully_delete_bulk(self.event, Order.objects.filter(pk=self.pk), user, auth)
|
||||||
|
|
||||||
def email_confirm_secret(self):
|
def email_confirm_secret(self):
|
||||||
return self.tagged_secret("email_confirm", 9)
|
return self.tagged_secret("email_confirm", 9)
|
||||||
@@ -1675,7 +1697,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
|
|||||||
self.company,
|
self.company,
|
||||||
self.street,
|
self.street,
|
||||||
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||||
self.country.name
|
self.country.name if self.country else ''
|
||||||
]
|
]
|
||||||
lines = [r.strip() for r in lines if r]
|
lines = [r.strip() for r in lines if r]
|
||||||
return '\n'.join(lines).strip()
|
return '\n'.join(lines).strip()
|
||||||
@@ -3416,7 +3438,7 @@ class InvoiceAddress(models.Model):
|
|||||||
self.name,
|
self.name,
|
||||||
self.street,
|
self.street,
|
||||||
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||||
self.country.name,
|
self.country.name if self.country else '',
|
||||||
self.vat_id,
|
self.vat_id,
|
||||||
self.custom_field,
|
self.custom_field,
|
||||||
self.internal_reference,
|
self.internal_reference,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import operator
|
|||||||
import string
|
import string
|
||||||
from datetime import date, datetime, time
|
from datetime import date, datetime, time
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytz_deprecation_shim
|
import pytz_deprecation_shim
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -61,6 +62,9 @@ from ...helpers.permission_migration import (
|
|||||||
from ..settings import settings_hierarkey
|
from ..settings import settings_hierarkey
|
||||||
from .auth import User
|
from .auth import User
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from hierarkey.proxy import HierarkeyProxy
|
||||||
|
|
||||||
|
|
||||||
@settings_hierarkey.add(cache_namespace='organizer')
|
@settings_hierarkey.add(cache_namespace='organizer')
|
||||||
class Organizer(LoggedModel):
|
class Organizer(LoggedModel):
|
||||||
@@ -78,6 +82,9 @@ class Organizer(LoggedModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
settings_namespace = 'organizer'
|
settings_namespace = 'organizer'
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
settings: HierarkeyProxy
|
||||||
|
|
||||||
name = models.CharField(max_length=200,
|
name = models.CharField(max_length=200,
|
||||||
verbose_name=_("Name"))
|
verbose_name=_("Name"))
|
||||||
slug = models.CharField(
|
slug = models.CharField(
|
||||||
|
|||||||
@@ -118,7 +118,10 @@ class SeatingPlan(LoggedModel):
|
|||||||
for zi, z in enumerate(self.layout_data['zones']):
|
for zi, z in enumerate(self.layout_data['zones']):
|
||||||
zpos = (z['position']['x'], z['position']['y'])
|
zpos = (z['position']['x'], z['position']['y'])
|
||||||
for ri, r in enumerate(z['rows']):
|
for ri, r in enumerate(z['rows']):
|
||||||
rpos = (zpos[0] + r['position']['x'], zpos[1] + r['position']['y'])
|
rpos = (
|
||||||
|
zpos[0] + r.get('position', {}).get('x', 0),
|
||||||
|
zpos[1] + r.get('position', {}).get('y', 0),
|
||||||
|
)
|
||||||
row_label = None
|
row_label = None
|
||||||
if r.get('row_label'):
|
if r.get('row_label'):
|
||||||
row_label = r['row_label'].replace("%s", r.get('row_number', str(ri)))
|
row_label = r['row_label'].replace("%s", r.get('row_number', str(ri)))
|
||||||
@@ -147,8 +150,8 @@ class SeatingPlan(LoggedModel):
|
|||||||
zone=z['name'],
|
zone=z['name'],
|
||||||
category=s['category'],
|
category=s['category'],
|
||||||
sorting_rank=rank,
|
sorting_rank=rank,
|
||||||
x=rpos[0] + s['position']['x'],
|
x=rpos[0] + s.get('position', {}).get('x', 0),
|
||||||
y=rpos[1] + s['position']['y'],
|
y=rpos[1] + s.get('position', {}).get('y', 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
|||||||
from pretix.base.models import Event, LogEntry
|
from pretix.base.models import Event, LogEntry
|
||||||
from pretix.base.signals import register_notification_types
|
from pretix.base.signals import register_notification_types
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
from pretix.helpers.urls import build_absolute_uri
|
from pretix.helpers.urls import mainreverse_absolute
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_ALL_TYPES = None
|
_ALL_TYPES = None
|
||||||
@@ -170,7 +170,7 @@ class ParametrizedOrderNotificationType(NotificationType):
|
|||||||
def build_notification(self, logentry: LogEntry):
|
def build_notification(self, logentry: LogEntry):
|
||||||
order = logentry.content_object
|
order = logentry.content_object
|
||||||
|
|
||||||
order_url = build_absolute_uri(
|
order_url = mainreverse_absolute(
|
||||||
'control:event.order',
|
'control:event.order',
|
||||||
kwargs={
|
kwargs={
|
||||||
'organizer': logentry.event.organizer.slug,
|
'organizer': logentry.event.organizer.slug,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ from pretix.helpers import OF_SELF
|
|||||||
from pretix.helpers.countries import CachedCountries
|
from pretix.helpers.countries import CachedCountries
|
||||||
from pretix.helpers.format import format_map
|
from pretix.helpers.format import format_map
|
||||||
from pretix.helpers.money import DecimalTextInput
|
from pretix.helpers.money import DecimalTextInput
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||||
from pretix.presale.views import get_cart
|
from pretix.presale.views import get_cart
|
||||||
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
|
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
|
||||||
|
|
||||||
@@ -379,7 +379,7 @@ class BasePaymentProvider:
|
|||||||
|
|
||||||
if not self.settings.get('_hidden_seed'):
|
if not self.settings.get('_hidden_seed'):
|
||||||
self.settings.set('_hidden_seed', get_random_string(64))
|
self.settings.set('_hidden_seed', get_random_string(64))
|
||||||
hidden_url = build_absolute_uri(self.event, 'presale:event.payment.unlock', kwargs={
|
hidden_url = eventreverse_absolute(self.event, 'presale:event.payment.unlock', kwargs={
|
||||||
'hash': hashlib.sha256((self.settings._hidden_seed + self.event.slug).encode()).hexdigest(),
|
'hash': hashlib.sha256((self.settings._hidden_seed + self.event.slug).encode()).hexdigest(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -834,7 +834,7 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError() # NOQA
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
|
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str | None:
|
||||||
"""
|
"""
|
||||||
After the user has confirmed their purchase, this method will be called to complete
|
After the user has confirmed their purchase, this method will be called to complete
|
||||||
the payment process. This is the place to actually move the money if applicable.
|
the payment process. This is the place to actually move the money if applicable.
|
||||||
|
|||||||
+16
-6
@@ -77,6 +77,7 @@ from reportlab.platypus import Paragraph
|
|||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import Checkin, Event, Order, OrderPosition, Question
|
from pretix.base.models import Checkin, Event, Order, OrderPosition, Question
|
||||||
|
from pretix.base.services.placeholders import PlaceholderContext
|
||||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||||
from pretix.base.signals import layout_image_variables, layout_text_variables
|
from pretix.base.signals import layout_image_variables, layout_text_variables
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
@@ -372,6 +373,11 @@ DEFAULT_VARIABLES = OrderedDict((
|
|||||||
"editor_sample": _("Atlantis"),
|
"editor_sample": _("Atlantis"),
|
||||||
"evaluate": lambda op, order, ev: str(getattr(order.invoice_address.country, 'name', '')) if getattr(order, 'invoice_address', None) else ''
|
"evaluate": lambda op, order, ev: str(getattr(order.invoice_address.country, 'name', '')) if getattr(order, 'invoice_address', None) else ''
|
||||||
}),
|
}),
|
||||||
|
("invoice_custom_field", {
|
||||||
|
"label": _("Invoice custom recipient field"),
|
||||||
|
"editor_sample": _("Custom recipient field"),
|
||||||
|
"evaluate": lambda op, order, ev: order.invoice_address.custom_field if getattr(order, 'invoice_address', None) else ''
|
||||||
|
}),
|
||||||
("addons", {
|
("addons", {
|
||||||
"label": _("List of Add-Ons"),
|
"label": _("List of Add-Ons"),
|
||||||
"editor_sample": _("Add-on 1\n2x Add-on 2"),
|
"editor_sample": _("Add-on 1\n2x Add-on 2"),
|
||||||
@@ -396,11 +402,7 @@ DEFAULT_VARIABLES = OrderedDict((
|
|||||||
"editor_sample": _("Event organizer info text"),
|
"editor_sample": _("Event organizer info text"),
|
||||||
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
|
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
|
||||||
}),
|
}),
|
||||||
("event_info_text", {
|
("event_info_text", {}), # Placeholder to "reserve" position, defined later in `get_variables`
|
||||||
"label": _("Event info text"),
|
|
||||||
"editor_sample": _("Event info text"),
|
|
||||||
"evaluate": lambda op, order, ev: str(order.event.settings.event_info_text)
|
|
||||||
}),
|
|
||||||
("now_date", {
|
("now_date", {
|
||||||
"label": _("Printing date"),
|
"label": _("Printing date"),
|
||||||
"editor_sample": _("2017-05-31"),
|
"editor_sample": _("2017-05-31"),
|
||||||
@@ -665,6 +667,14 @@ def get_images(event):
|
|||||||
def get_variables(event):
|
def get_variables(event):
|
||||||
v = copy.copy(DEFAULT_VARIABLES)
|
v = copy.copy(DEFAULT_VARIABLES)
|
||||||
|
|
||||||
|
templating_context = PlaceholderContext(event=event)
|
||||||
|
v['event_info_text'] = {
|
||||||
|
"label": _("Event info text"),
|
||||||
|
"editor_sample": _("Event info text"),
|
||||||
|
"evaluate": lambda op, order, ev:
|
||||||
|
templating_context.format(str(order.event.settings.event_info_text))
|
||||||
|
}
|
||||||
|
|
||||||
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||||
|
|
||||||
concatenation_for_salutation = scheme.get("concatenation_for_salutation", scheme["concatenation"])
|
concatenation_for_salutation = scheme.get("concatenation_for_salutation", scheme["concatenation"])
|
||||||
@@ -1062,7 +1072,7 @@ class Renderer:
|
|||||||
except:
|
except:
|
||||||
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
|
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
|
||||||
|
|
||||||
p = Paragraph(text, style=style)
|
p = Paragraph(text, style=style) # not using AutoEscapeParagraph is safe as we escape above
|
||||||
return p, ad, lineheight
|
return p, ad, lineheight
|
||||||
|
|
||||||
def _draw_textcontainer(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
def _draw_textcontainer(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery
|
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
@@ -377,12 +378,13 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
|||||||
confirmation_code = get_random_string(8, allowed_chars="01234567890")
|
confirmation_code = get_random_string(8, allowed_chars="01234567890")
|
||||||
mail(
|
mail(
|
||||||
user.email,
|
user.email,
|
||||||
subject=gettext('Bulk-refund confirmation'),
|
subject=gettext('Confirm event cancellation and bulk refund'),
|
||||||
template='pretixbase/email/cancel_confirm.txt',
|
template='pretixbase/email/cancel_confirm.txt',
|
||||||
context={
|
context={
|
||||||
"event": str(event),
|
"event": str(event),
|
||||||
"amount": money_filter(refund_total, event.currency),
|
"amount": money_filter(refund_total, event.currency),
|
||||||
"confirmation_code": confirmation_code,
|
"confirmation_code": confirmation_code,
|
||||||
|
"instance": settings.PRETIX_INSTANCE_NAME,
|
||||||
},
|
},
|
||||||
locale=user.locale,
|
locale=user.locale,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -287,11 +287,11 @@ def _check_position_constraints(
|
|||||||
raise CartPositionError(error_messages['unavailable'])
|
raise CartPositionError(error_messages['unavailable'])
|
||||||
|
|
||||||
# Invalid media policy for online sale
|
# Invalid media policy for online sale
|
||||||
if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
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):
|
||||||
mt = MEDIA_TYPES[item.media_type]
|
mt = MEDIA_TYPES[item.media_type]
|
||||||
if not mt.medium_created_by_server:
|
if not mt.medium_created_by_server:
|
||||||
raise CartPositionError(error_messages['media_usage_not_implemented'])
|
raise CartPositionError(error_messages['media_usage_not_implemented'])
|
||||||
elif item.media_policy == Item.MEDIA_POLICY_REUSE:
|
elif item.media_policy in (Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_APPEND):
|
||||||
raise CartPositionError(error_messages['media_usage_not_implemented'])
|
raise CartPositionError(error_messages['media_usage_not_implemented'])
|
||||||
|
|
||||||
# Item removed from sales channel
|
# Item removed from sales channel
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import dateutil
|
|||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
from dateutil.tz import datetime_exists
|
from dateutil.tz import datetime_exists
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min,
|
BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min,
|
||||||
OuterRef, Q, Subquery, TextField, Value, When,
|
OuterRef, Q, Subquery, TextField, Value, When,
|
||||||
@@ -59,6 +59,7 @@ from pretix.base.models import (
|
|||||||
)
|
)
|
||||||
from pretix.base.signals import checkin_created, periodic_task
|
from pretix.base.signals import checkin_created, periodic_task
|
||||||
from pretix.helpers import OF_SELF
|
from pretix.helpers import OF_SELF
|
||||||
|
from pretix.helpers.database import conditional_atomic
|
||||||
from pretix.helpers.jsonlogic import Logic
|
from pretix.helpers.jsonlogic import Logic
|
||||||
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
|
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
|
||||||
from pretix.helpers.jsonlogic_query import (
|
from pretix.helpers.jsonlogic_query import (
|
||||||
@@ -867,6 +868,15 @@ class RequiredQuestionsError(Exception):
|
|||||||
super().__init__(msg)
|
super().__init__(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class RequiredMediaExchangeError(Exception):
|
||||||
|
def __init__(self, msg, code, media_policy, media_type):
|
||||||
|
self.msg = msg
|
||||||
|
self.code = code
|
||||||
|
self.media_policy = media_policy
|
||||||
|
self.media_type = media_type
|
||||||
|
super().__init__(msg)
|
||||||
|
|
||||||
|
|
||||||
def _save_answers(op, answers, given_answers):
|
def _save_answers(op, answers, given_answers):
|
||||||
def _create_answer(question, answer):
|
def _create_answer(question, answer):
|
||||||
try:
|
try:
|
||||||
@@ -939,7 +949,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
|||||||
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
|
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
|
||||||
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
|
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
|
||||||
raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False,
|
raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False,
|
||||||
gate=None):
|
gate=None, reusable_medium=None):
|
||||||
"""
|
"""
|
||||||
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
|
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
|
||||||
not valid at this time.
|
not valid at this time.
|
||||||
@@ -955,6 +965,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
|||||||
:param datetime: The datetime of the checkin, defaults to now.
|
:param datetime: The datetime of the checkin, defaults to now.
|
||||||
:param simulate: If true, the check-in is not saved.
|
:param simulate: If true, the check-in is not saved.
|
||||||
:param gate: The gate the check-in was performed at.
|
:param gate: The gate the check-in was performed at.
|
||||||
|
:param reusable_medium: The medium that is available for an exchange
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# !!!!!!!!!
|
# !!!!!!!!!
|
||||||
@@ -1033,10 +1044,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
|||||||
if not simulate:
|
if not simulate:
|
||||||
_save_answers(op, answers, given_answers)
|
_save_answers(op, answers, given_answers)
|
||||||
|
|
||||||
with transaction.atomic():
|
with conditional_atomic(not simulate):
|
||||||
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
|
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
|
||||||
opqs = OrderPosition.all
|
opqs = OrderPosition.all.select_related("order", "item")
|
||||||
if type != Checkin.TYPE_EXIT:
|
if type != Checkin.TYPE_EXIT and not simulate:
|
||||||
opqs = opqs.select_for_update(of=OF_SELF)
|
opqs = opqs.select_for_update(of=OF_SELF)
|
||||||
op = opqs.get(pk=op.pk)
|
op = opqs.get(pk=op.pk)
|
||||||
|
|
||||||
@@ -1101,6 +1112,24 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
|||||||
require_answers
|
require_answers
|
||||||
)
|
)
|
||||||
|
|
||||||
|
required_media_policy = op.item.media_policy
|
||||||
|
required_media_type = op.item.media_type
|
||||||
|
require_a_medium = required_media_policy and required_media_type
|
||||||
|
linked_media = op.linked_media
|
||||||
|
if require_a_medium and not reusable_medium and not force:
|
||||||
|
if not linked_media.exists():
|
||||||
|
raise RequiredMediaExchangeError(
|
||||||
|
_('Ticket needs to be exchanged to a suitable medium.'),
|
||||||
|
'exchange',
|
||||||
|
required_media_policy,
|
||||||
|
required_media_type
|
||||||
|
)
|
||||||
|
elif op.organizer.settings.reusable_media_usage_enforced:
|
||||||
|
raise CheckInError(
|
||||||
|
_('This ticket has already been exchanged for a reusable medium that now needs to be used instead.'),
|
||||||
|
'already_exchanged',
|
||||||
|
)
|
||||||
|
|
||||||
device = None
|
device = None
|
||||||
if isinstance(auth, Device):
|
if isinstance(auth, Device):
|
||||||
device = auth
|
device = auth
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ from pretix.base.signals import (
|
|||||||
)
|
)
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.helpers import OF_SELF, repeatable_reads_transaction
|
from pretix.helpers import OF_SELF, repeatable_reads_transaction
|
||||||
from pretix.helpers.urls import build_absolute_uri
|
from pretix.helpers.urls import mainreverse_absolute
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -340,12 +340,13 @@ def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter,
|
|||||||
if schedule.owner.is_active:
|
if schedule.owner.is_active:
|
||||||
mail(
|
mail(
|
||||||
email=schedule.owner.email,
|
email=schedule.owner.email,
|
||||||
subject=gettext('Export failed'),
|
subject=gettext('Scheduled export failed'),
|
||||||
template='pretixbase/email/export_failed.txt',
|
template='pretixbase/email/export_failed.txt',
|
||||||
context={
|
context={
|
||||||
'configuration_url': config_url,
|
'configuration_url': config_url,
|
||||||
'reason': msg,
|
'reason': msg,
|
||||||
'soft': soft,
|
'soft': soft,
|
||||||
|
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||||
},
|
},
|
||||||
event=context if isinstance(context, Event) else None,
|
event=context if isinstance(context, Event) else None,
|
||||||
organizer=context.organizer if isinstance(context, Event) else context,
|
organizer=context.organizer if isinstance(context, Event) else context,
|
||||||
@@ -455,7 +456,7 @@ def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> Non
|
|||||||
schedule,
|
schedule,
|
||||||
organizer,
|
organizer,
|
||||||
exporter,
|
exporter,
|
||||||
build_absolute_uri(
|
mainreverse_absolute(
|
||||||
'control:organizer.export',
|
'control:organizer.export',
|
||||||
kwargs={
|
kwargs={
|
||||||
'organizer': organizer.slug,
|
'organizer': organizer.slug,
|
||||||
@@ -481,7 +482,7 @@ def scheduled_event_export(self, event: Event, schedule: int) -> None:
|
|||||||
schedule,
|
schedule,
|
||||||
event,
|
event,
|
||||||
exporter,
|
exporter,
|
||||||
build_absolute_uri(
|
mainreverse_absolute(
|
||||||
'control:event.orders.export',
|
'control:event.orders.export',
|
||||||
kwargs={
|
kwargs={
|
||||||
'event': event.slug,
|
'event': event.slug,
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ from pretix.helpers.format import (
|
|||||||
FormattedString, PlainHtmlAlternativeString, SafeFormatter, format_map,
|
FormattedString, PlainHtmlAlternativeString, SafeFormatter, format_map,
|
||||||
)
|
)
|
||||||
from pretix.helpers.hierarkey import clean_filename
|
from pretix.helpers.hierarkey import clean_filename
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||||
from pretix.presale.ical import get_private_icals
|
from pretix.presale.ical import get_private_icals
|
||||||
|
|
||||||
logger = logging.getLogger('pretix.base.mail')
|
logger = logging.getLogger('pretix.base.mail')
|
||||||
@@ -997,7 +997,7 @@ def _wrap_plain_body(content_plain, signature, event, order, position, no_order_
|
|||||||
body_plain += _(
|
body_plain += _(
|
||||||
"You can view your order details at the following URL:\n{orderurl}."
|
"You can view your order details at the following URL:\n{orderurl}."
|
||||||
).replace("\n", "\r\n").format(
|
).replace("\n", "\r\n").format(
|
||||||
orderurl=build_absolute_uri(
|
orderurl=eventreverse_absolute(
|
||||||
order.event, 'presale:event.order.position', kwargs={
|
order.event, 'presale:event.order.position', kwargs={
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
'secret': position.web_secret,
|
'secret': position.web_secret,
|
||||||
@@ -1013,7 +1013,7 @@ def _wrap_plain_body(content_plain, signature, event, order, position, no_order_
|
|||||||
body_plain += _(
|
body_plain += _(
|
||||||
"You can view your order details at the following URL:\n{orderurl}."
|
"You can view your order details at the following URL:\n{orderurl}."
|
||||||
).replace("\n", "\r\n").format(
|
).replace("\n", "\r\n").format(
|
||||||
event=event.name, orderurl=build_absolute_uri(
|
event=event.name, orderurl=eventreverse_absolute(
|
||||||
order.event, 'presale:event.order.open', kwargs={
|
order.event, 'presale:event.order.open', kwargs={
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
'secret': order.secret,
|
'secret': order.secret,
|
||||||
|
|||||||
@@ -23,10 +23,13 @@ import secrets
|
|||||||
|
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
from pretix.base.models import GiftCardAcceptance
|
from pretix.base.media import MEDIA_TYPES
|
||||||
from pretix.base.models.media import MediumKeySet
|
from pretix.base.models import Checkin, GiftCardAcceptance, Item
|
||||||
|
from pretix.base.models.media import MediumKeySet, ReusableMedium
|
||||||
|
from pretix.base.services.checkin import CheckInError
|
||||||
|
|
||||||
|
|
||||||
def create_nfc_mf0aes_keyset(organizer):
|
def create_nfc_mf0aes_keyset(organizer):
|
||||||
@@ -70,3 +73,174 @@ def get_keysets_for_organizer(organizer):
|
|||||||
if new_set:
|
if new_set:
|
||||||
sets.append(new_set)
|
sets.append(new_set)
|
||||||
return sets
|
return sets
|
||||||
|
|
||||||
|
|
||||||
|
def perform_media_exchange(organizer, media_type, identifier, link_orderposition, user, auth):
|
||||||
|
"""
|
||||||
|
Create or retrieve a medium, then link the order position to it. Expected to be called in a transaction.
|
||||||
|
|
||||||
|
:param organizer: Organizer to operate in
|
||||||
|
:param media_type: Type of medium to operate with
|
||||||
|
:param identifier: Identifier of the medium
|
||||||
|
:param link_orderposition: Position to link to the medium
|
||||||
|
:return: ReusableMedium
|
||||||
|
"""
|
||||||
|
medium = None
|
||||||
|
media_policy = link_orderposition.item.media_policy
|
||||||
|
|
||||||
|
if media_type not in MEDIA_TYPES: # should be caught by serializer already
|
||||||
|
raise CheckInError(
|
||||||
|
_('Invalid medium type.'),
|
||||||
|
Checkin.REASON_ERROR,
|
||||||
|
reason=_('Invalid medium type.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not MEDIA_TYPES[media_type].is_active(organizer):
|
||||||
|
raise CheckInError(
|
||||||
|
_('Medium type is not enabled for organizer.'),
|
||||||
|
Checkin.REASON_ERROR,
|
||||||
|
reason=_('Medium type is not enabled for organizer.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if link_orderposition.item.media_type != media_type:
|
||||||
|
raise CheckInError(
|
||||||
|
_('Incorrect medium type for product.'),
|
||||||
|
Checkin.REASON_PRODUCT,
|
||||||
|
reason=_('Incorrect medium type for product.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if link_orderposition.linked_media.exists():
|
||||||
|
raise CheckInError(
|
||||||
|
_('Ticket is already exchanged for reusable medium.'),
|
||||||
|
Checkin.REASON_ALREADY_EXCHANGED,
|
||||||
|
reason=_('Ticket is already exchanged for reusable medium.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if media_policy in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_NEW):
|
||||||
|
link_action = "append"
|
||||||
|
else:
|
||||||
|
link_action = "replace"
|
||||||
|
|
||||||
|
if media_policy in (Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_APPEND):
|
||||||
|
try:
|
||||||
|
medium = ReusableMedium.objects.get(
|
||||||
|
type=media_type,
|
||||||
|
identifier=identifier,
|
||||||
|
organizer=organizer,
|
||||||
|
)
|
||||||
|
except ReusableMedium.DoesNotExist:
|
||||||
|
raise CheckInError(
|
||||||
|
_('Reusable medium not found.'),
|
||||||
|
Checkin.REASON_MEDIUM_INVALID,
|
||||||
|
reason=_('Reusable medium not found.'),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if medium.is_expired or not medium.active:
|
||||||
|
raise CheckInError(
|
||||||
|
_('Reusable medium is inactive or expired.'),
|
||||||
|
Checkin.REASON_MEDIUM_INVALID,
|
||||||
|
reason=_('Reusable medium is inactive or expired.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif media_policy in (Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW):
|
||||||
|
try:
|
||||||
|
medium = ReusableMedium.objects.get(
|
||||||
|
type=media_type,
|
||||||
|
identifier=identifier,
|
||||||
|
organizer=organizer,
|
||||||
|
)
|
||||||
|
except ReusableMedium.DoesNotExist:
|
||||||
|
if not MEDIA_TYPES[media_type].medium_created_from_unknown_supported:
|
||||||
|
raise CheckInError(
|
||||||
|
_('Reusable medium not found and could not be created.'),
|
||||||
|
Checkin.REASON_MEDIUM_INVALID,
|
||||||
|
)
|
||||||
|
|
||||||
|
medium = MEDIA_TYPES[media_type].handle_unknown(organizer, identifier, user, auth, force_create=True)
|
||||||
|
if not medium:
|
||||||
|
raise CheckInError(
|
||||||
|
_('Reusable medium not found and could not be created.'),
|
||||||
|
Checkin.REASON_MEDIUM_INVALID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if medium.is_expired or not medium.active:
|
||||||
|
raise CheckInError(
|
||||||
|
_('Reusable medium is inactive or expired.'),
|
||||||
|
Checkin.REASON_MEDIUM_INVALID,
|
||||||
|
reason=_('Reusable medium is inactive or expired.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif media_policy == Item.MEDIA_POLICY_NEW:
|
||||||
|
if not MEDIA_TYPES[media_type].medium_created_from_unknown_supported:
|
||||||
|
raise CheckInError(
|
||||||
|
_('Reusable medium not found and could not be created.'),
|
||||||
|
Checkin.REASON_MEDIUM_INVALID,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
medium = MEDIA_TYPES[media_type].handle_unknown(organizer, identifier, user, auth, force_create=True)
|
||||||
|
except IntegrityError:
|
||||||
|
raise CheckInError(
|
||||||
|
_('Reusable medium already exists.'),
|
||||||
|
Checkin.REASON_MEDIUM_EXISTS,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if not medium:
|
||||||
|
raise CheckInError(
|
||||||
|
_('Reusable medium could not be created.'),
|
||||||
|
Checkin.REASON_MEDIUM_INVALID,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise CheckInError(
|
||||||
|
_('Product does not support medium exchange.'),
|
||||||
|
Checkin.REASON_PRODUCT,
|
||||||
|
reason=_('Product does not support medium exchange.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if link_action == 'append':
|
||||||
|
medium.linked_orderpositions.add(link_orderposition)
|
||||||
|
medium.log_action(
|
||||||
|
'pretix.reusable_medium.linked_orderposition.added',
|
||||||
|
user=user,
|
||||||
|
auth=auth,
|
||||||
|
data={
|
||||||
|
'linked_orderposition': link_orderposition,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif link_action == 'replace':
|
||||||
|
already_found = False
|
||||||
|
for op_pk in medium.linked_orderpositions.values_list('pk', flat=True):
|
||||||
|
if op_pk == link_orderposition.pk:
|
||||||
|
already_found = True
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
medium.log_action(
|
||||||
|
'pretix.reusable_medium.linked_orderposition.removed',
|
||||||
|
data={
|
||||||
|
'linked_orderposition': op_pk,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not already_found:
|
||||||
|
medium.linked_orderpositions.set([link_orderposition])
|
||||||
|
medium.log_action(
|
||||||
|
'pretix.reusable_medium.linked_orderposition.added',
|
||||||
|
user=user,
|
||||||
|
auth=auth,
|
||||||
|
data={
|
||||||
|
'linked_orderposition': link_orderposition,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
link_orderposition.order.log_action(
|
||||||
|
'pretix.reusable_medium.exchanged',
|
||||||
|
data={
|
||||||
|
'position': link_orderposition.pk,
|
||||||
|
'positionid': link_orderposition.positionid,
|
||||||
|
'medium': medium.pk,
|
||||||
|
'medium_identifier': medium.identifier,
|
||||||
|
'medium_type': medium.media_type.identifier,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
medium.touch()
|
||||||
|
|
||||||
|
return medium
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ from typing import List
|
|||||||
from django.conf import settings as django_settings
|
from django.conf import settings as django_settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.db.utils import IntegrityError
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _, ngettext
|
||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.modelimport import DataImportError, ImportColumn, parse_csv
|
from pretix.base.modelimport import DataImportError, ImportColumn, parse_csv
|
||||||
@@ -260,6 +261,7 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
|
|||||||
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
|
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
|
||||||
# shorter. We'll see what works better in reality…
|
# shorter. We'll see what works better in reality…
|
||||||
vouchers = []
|
vouchers = []
|
||||||
|
codes = set()
|
||||||
lock_seats = []
|
lock_seats = []
|
||||||
for i, record in enumerate(data):
|
for i, record in enumerate(data):
|
||||||
try:
|
try:
|
||||||
@@ -268,6 +270,14 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
|
|||||||
|
|
||||||
if not record.get("code"):
|
if not record.get("code"):
|
||||||
raise ValidationError(_('A voucher cannot be created without a code.'))
|
raise ValidationError(_('A voucher cannot be created without a code.'))
|
||||||
|
code = record.get("code")
|
||||||
|
if code.upper() in codes:
|
||||||
|
raise ValidationError(
|
||||||
|
_('Voucher codes must be unique. Code "{code}" already exists in this import.').format(
|
||||||
|
code=code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
codes.add(code.upper())
|
||||||
Voucher.clean_item_properties(
|
Voucher.clean_item_properties(
|
||||||
record,
|
record,
|
||||||
event,
|
event,
|
||||||
@@ -286,8 +296,22 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
|
|||||||
lock_seats.append(voucher.seat)
|
lock_seats.append(voucher.seat)
|
||||||
except (ValidationError, ImportError) as e:
|
except (ValidationError, ImportError) as e:
|
||||||
raise DataImportError(
|
raise DataImportError(
|
||||||
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
|
_('Invalid data in row {row}: {message}').format(row=i + 1, message=str(e))
|
||||||
)
|
)
|
||||||
|
existing_codes = Voucher.objects.filter(
|
||||||
|
event=event,
|
||||||
|
code__in=codes,
|
||||||
|
).values_list("code", flat=True)
|
||||||
|
if len(existing_codes):
|
||||||
|
raise DataImportError(
|
||||||
|
ngettext(
|
||||||
|
'Voucher codes must be unique. Import contains existing voucher code {code}.',
|
||||||
|
'Voucher codes must be unique. Import contains existing voucher codes {code}.',
|
||||||
|
len(existing_codes)
|
||||||
|
).format(
|
||||||
|
code=", ".join(existing_codes)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# We don't support quotas here, so we only need to lock if seats are in use
|
# We don't support quotas here, so we only need to lock if seats are in use
|
||||||
@@ -300,7 +324,13 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
|
|||||||
|
|
||||||
save_logentries = []
|
save_logentries = []
|
||||||
for v in vouchers:
|
for v in vouchers:
|
||||||
v.save()
|
try:
|
||||||
|
v.save()
|
||||||
|
except IntegrityError:
|
||||||
|
# should not happen as we check existing codes before, but we did not lock so we might have a race-condition
|
||||||
|
raise DataImportError(
|
||||||
|
_('Vouchers could not be imported, probably due to a voucher code already being in use.')
|
||||||
|
)
|
||||||
save_logentries.append(v.log_action(
|
save_logentries.append(v.log_action(
|
||||||
'pretix.voucher.added',
|
'pretix.voucher.added',
|
||||||
user=user,
|
user=user,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
|||||||
from pretix.base.signals import notification
|
from pretix.base.signals import notification
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.helpers.celery import get_task_priority
|
from pretix.helpers.celery import get_task_priority
|
||||||
from pretix.helpers.urls import build_absolute_uri
|
from pretix.helpers.urls import mainreverse_absolute
|
||||||
|
|
||||||
|
|
||||||
@app.task(base=TransactionAwareTask, acks_late=True, max_retries=9, default_retry_delay=900)
|
@app.task(base=TransactionAwareTask, acks_late=True, max_retries=9, default_retry_delay=900)
|
||||||
@@ -136,10 +136,10 @@ def send_notification_mail(notification: Notification, user: User):
|
|||||||
'site_url': settings.SITE_URL,
|
'site_url': settings.SITE_URL,
|
||||||
'color': settings.PRETIX_PRIMARY_COLOR,
|
'color': settings.PRETIX_PRIMARY_COLOR,
|
||||||
'notification': notification,
|
'notification': notification,
|
||||||
'settings_url': build_absolute_uri(
|
'settings_url': mainreverse_absolute(
|
||||||
'control:user.settings.notifications',
|
'control:user.settings.notifications',
|
||||||
),
|
),
|
||||||
'disable_url': build_absolute_uri(
|
'disable_url': mainreverse_absolute(
|
||||||
'control:user.settings.notifications.off',
|
'control:user.settings.notifications.off',
|
||||||
kwargs={
|
kwargs={
|
||||||
'token': user.notifications_token,
|
'token': user.notifications_token,
|
||||||
|
|||||||
@@ -3506,7 +3506,7 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
|
|||||||
from pretix.base.models import ReusableMedium
|
from pretix.base.models import ReusableMedium
|
||||||
|
|
||||||
for p in order.positions.all():
|
for p in order.positions.all():
|
||||||
if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW):
|
||||||
mt = MEDIA_TYPES[p.item.media_type]
|
mt = MEDIA_TYPES[p.item.media_type]
|
||||||
if mt.medium_created_by_server and not p.linked_media.exists():
|
if mt.medium_created_by_server and not p.linked_media.exists():
|
||||||
rm = ReusableMedium.objects.create(
|
rm = ReusableMedium.objects.create(
|
||||||
@@ -3515,8 +3515,8 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
|
|||||||
identifier=mt.generate_identifier(sender.organizer),
|
identifier=mt.generate_identifier(sender.organizer),
|
||||||
active=True,
|
active=True,
|
||||||
customer=order.customer,
|
customer=order.customer,
|
||||||
linked_orderposition=p,
|
|
||||||
)
|
)
|
||||||
|
rm.linked_orderpositions.add(p)
|
||||||
rm.log_action(
|
rm.log_action(
|
||||||
'pretix.reusable_medium.created',
|
'pretix.reusable_medium.created',
|
||||||
data={
|
data={
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ def get_best_name(position_or_address, parts=False):
|
|||||||
|
|
||||||
@receiver(register_text_placeholders, dispatch_uid="pretixbase_register_text_placeholders")
|
@receiver(register_text_placeholders, dispatch_uid="pretixbase_register_text_placeholders")
|
||||||
def base_placeholders(sender, **kwargs):
|
def base_placeholders(sender, **kwargs):
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||||
|
|
||||||
def _event_sample(event):
|
def _event_sample(event):
|
||||||
if event.has_subevents:
|
if event.has_subevents:
|
||||||
@@ -388,14 +388,14 @@ def base_placeholders(sender, **kwargs):
|
|||||||
lambda event: LazyDate(now() + timedelta(days=15))
|
lambda event: LazyDate(now() + timedelta(days=15))
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'url', ['order', 'event'], lambda order, event: build_absolute_uri(
|
'url', ['order', 'event'], lambda order, event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.open', kwargs={
|
'presale:event.order.open', kwargs={
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
'secret': order.secret,
|
'secret': order.secret,
|
||||||
'hash': order.email_confirm_secret()
|
'hash': order.email_confirm_secret()
|
||||||
}
|
}
|
||||||
), lambda event: build_absolute_uri(
|
), lambda event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.open', kwargs={
|
'presale:event.order.open', kwargs={
|
||||||
'order': 'F8VVL',
|
'order': 'F8VVL',
|
||||||
@@ -406,7 +406,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
),
|
),
|
||||||
SimpleButtonPlaceholder(
|
SimpleButtonPlaceholder(
|
||||||
'url_button', ['order', 'event'],
|
'url_button', ['order', 'event'],
|
||||||
url_func=lambda order, event: build_absolute_uri(
|
url_func=lambda order, event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.open', kwargs={
|
'presale:event.order.open', kwargs={
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
@@ -415,7 +415,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
text_func=lambda order, event: _("View order details"),
|
text_func=lambda order, event: _("View order details"),
|
||||||
sample_url_func=lambda event: build_absolute_uri(
|
sample_url_func=lambda event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.open', kwargs={
|
'presale:event.order.open', kwargs={
|
||||||
'order': 'F8VVL',
|
'order': 'F8VVL',
|
||||||
@@ -426,13 +426,13 @@ def base_placeholders(sender, **kwargs):
|
|||||||
sample_text_func=lambda event: _("View order details"),
|
sample_text_func=lambda event: _("View order details"),
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
|
'url_info_change', ['order', 'event'], lambda order, event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.modify', kwargs={
|
'presale:event.order.modify', kwargs={
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
'secret': order.secret,
|
'secret': order.secret,
|
||||||
}
|
}
|
||||||
), lambda event: build_absolute_uri(
|
), lambda event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.modify', kwargs={
|
'presale:event.order.modify', kwargs={
|
||||||
'order': 'F8VVL',
|
'order': 'F8VVL',
|
||||||
@@ -441,13 +441,13 @@ def base_placeholders(sender, **kwargs):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'url_products_change', ['order', 'event'], lambda order, event: build_absolute_uri(
|
'url_products_change', ['order', 'event'], lambda order, event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.change', kwargs={
|
'presale:event.order.change', kwargs={
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
'secret': order.secret,
|
'secret': order.secret,
|
||||||
}
|
}
|
||||||
), lambda event: build_absolute_uri(
|
), lambda event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.change', kwargs={
|
'presale:event.order.change', kwargs={
|
||||||
'order': 'F8VVL',
|
'order': 'F8VVL',
|
||||||
@@ -456,13 +456,13 @@ def base_placeholders(sender, **kwargs):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'url_cancel', ['order', 'event'], lambda order, event: build_absolute_uri(
|
'url_cancel', ['order', 'event'], lambda order, event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.cancel', kwargs={
|
'presale:event.order.cancel', kwargs={
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
'secret': order.secret,
|
'secret': order.secret,
|
||||||
}
|
}
|
||||||
), lambda event: build_absolute_uri(
|
), lambda event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.cancel', kwargs={
|
'presale:event.order.cancel', kwargs={
|
||||||
'order': 'F8VVL',
|
'order': 'F8VVL',
|
||||||
@@ -471,7 +471,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'url', ['event', 'position'], lambda event, position: build_absolute_uri(
|
'url', ['event', 'position'], lambda event, position: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.position',
|
'presale:event.order.position',
|
||||||
kwargs={
|
kwargs={
|
||||||
@@ -480,7 +480,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
'position': position.positionid
|
'position': position.positionid
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
lambda event: build_absolute_uri(
|
lambda event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.position', kwargs={
|
'presale:event.order.position', kwargs={
|
||||||
'order': 'F8VVL',
|
'order': 'F8VVL',
|
||||||
@@ -491,7 +491,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
),
|
),
|
||||||
SimpleButtonPlaceholder(
|
SimpleButtonPlaceholder(
|
||||||
'url_button', ['event', 'position'],
|
'url_button', ['event', 'position'],
|
||||||
url_func=lambda event, position: build_absolute_uri(
|
url_func=lambda event, position: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.position', kwargs={
|
'presale:event.order.position', kwargs={
|
||||||
'order': position.order.code,
|
'order': position.order.code,
|
||||||
@@ -500,7 +500,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
text_func=lambda event, position: _("View registration details"),
|
text_func=lambda event, position: _("View registration details"),
|
||||||
sample_url_func=lambda event: build_absolute_uri(
|
sample_url_func=lambda event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.position', kwargs={
|
'presale:event.order.position', kwargs={
|
||||||
'order': 'F8VVL',
|
'order': 'F8VVL',
|
||||||
@@ -511,14 +511,14 @@ def base_placeholders(sender, **kwargs):
|
|||||||
sample_text_func=lambda event: _("View registration details"),
|
sample_text_func=lambda event: _("View registration details"),
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri(
|
'url_info_change', ['position', 'event'], lambda position, event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.position.modify', kwargs={
|
'presale:event.order.position.modify', kwargs={
|
||||||
'order': position.order.code,
|
'order': position.order.code,
|
||||||
'secret': position.web_secret,
|
'secret': position.web_secret,
|
||||||
'position': position.positionid
|
'position': position.positionid
|
||||||
}
|
}
|
||||||
), lambda event: build_absolute_uri(
|
), lambda event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.position.modify', kwargs={
|
'presale:event.order.position.modify', kwargs={
|
||||||
'order': 'F8VVL',
|
'order': 'F8VVL',
|
||||||
@@ -528,14 +528,14 @@ def base_placeholders(sender, **kwargs):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'url_products_change', ['position', 'event'], lambda position, event: build_absolute_uri(
|
'url_products_change', ['position', 'event'], lambda position, event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.position.change', kwargs={
|
'presale:event.order.position.change', kwargs={
|
||||||
'order': position.order.code,
|
'order': position.order.code,
|
||||||
'secret': position.web_secret,
|
'secret': position.web_secret,
|
||||||
'position': position.positionid
|
'position': position.positionid
|
||||||
}
|
}
|
||||||
), lambda event: build_absolute_uri(
|
), lambda event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.order.position.change', kwargs={
|
'presale:event.order.position.change', kwargs={
|
||||||
'order': 'F8VVL',
|
'order': 'F8VVL',
|
||||||
@@ -581,20 +581,20 @@ def base_placeholders(sender, **kwargs):
|
|||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'url_remove', ['waiting_list_voucher', 'event'],
|
'url_remove', ['waiting_list_voucher', 'event'],
|
||||||
lambda waiting_list_voucher, event: build_absolute_uri(
|
lambda waiting_list_voucher, event: eventreverse_absolute(
|
||||||
event, 'presale:event.waitinglist.remove'
|
event, 'presale:event.waitinglist.remove'
|
||||||
) + '?voucher=' + waiting_list_voucher.code,
|
) + '?voucher=' + waiting_list_voucher.code,
|
||||||
lambda event: build_absolute_uri(
|
lambda event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.waitinglist.remove',
|
'presale:event.waitinglist.remove',
|
||||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'url', ['waiting_list_voucher', 'event'],
|
'url', ['waiting_list_voucher', 'event'],
|
||||||
lambda waiting_list_voucher, event: build_absolute_uri(
|
lambda waiting_list_voucher, event: eventreverse_absolute(
|
||||||
event, 'presale:event.redeem'
|
event, 'presale:event.redeem'
|
||||||
) + '?voucher=' + waiting_list_voucher.code,
|
) + '?voucher=' + waiting_list_voucher.code,
|
||||||
lambda event: build_absolute_uri(
|
lambda event: eventreverse_absolute(
|
||||||
event,
|
event,
|
||||||
'presale:event.redeem',
|
'presale:event.redeem',
|
||||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||||
@@ -611,7 +611,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
||||||
'* {} - {}'.format(
|
'* {} - {}'.format(
|
||||||
order.full_code,
|
order.full_code,
|
||||||
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
eventreverse_absolute(event, 'presale:event.order.open', kwargs={
|
||||||
'event': event.slug,
|
'event': event.slug,
|
||||||
'organizer': event.organizer.slug,
|
'organizer': event.organizer.slug,
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
@@ -623,7 +623,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
), lambda event: '\n' + '\n\n'.join(
|
), lambda event: '\n' + '\n\n'.join(
|
||||||
'* {} - {}'.format(
|
'* {} - {}'.format(
|
||||||
'{}-{}'.format(event.slug.upper(), order['code']),
|
'{}-{}'.format(event.slug.upper(), order['code']),
|
||||||
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
eventreverse_absolute(event, 'presale:event.order.open', kwargs={
|
||||||
'event': event.slug,
|
'event': event.slug,
|
||||||
'organizer': event.organizer.slug,
|
'organizer': event.organizer.slug,
|
||||||
'order': order['code'],
|
'order': order['code'],
|
||||||
@@ -662,13 +662,13 @@ def base_placeholders(sender, **kwargs):
|
|||||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||||
'voucher_url_list', ['event', 'voucher_list'],
|
'voucher_url_list', ['event', 'voucher_list'],
|
||||||
lambda event, voucher_list: ' \n'.join([
|
lambda event, voucher_list: ' \n'.join([
|
||||||
build_absolute_uri(
|
eventreverse_absolute(
|
||||||
event, 'presale:event.redeem'
|
event, 'presale:event.redeem'
|
||||||
) + '?voucher=' + c
|
) + '?voucher=' + c
|
||||||
for c in voucher_list
|
for c in voucher_list
|
||||||
]),
|
]),
|
||||||
lambda event: ' \n'.join([
|
lambda event: ' \n'.join([
|
||||||
build_absolute_uri(
|
eventreverse_absolute(
|
||||||
event, 'presale:event.redeem'
|
event, 'presale:event.redeem'
|
||||||
) + '?voucher=' + c
|
) + '?voucher=' + c
|
||||||
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
|
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
|
||||||
@@ -676,10 +676,10 @@ def base_placeholders(sender, **kwargs):
|
|||||||
inline=False,
|
inline=False,
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
|
'url', ['event', 'voucher_list'], lambda event, voucher_list: eventreverse_absolute(event, 'presale:event.index', kwargs={
|
||||||
'event': event.slug,
|
'event': event.slug,
|
||||||
'organizer': event.organizer.slug,
|
'organizer': event.organizer.slug,
|
||||||
}), lambda event: build_absolute_uri(event, 'presale:event.index', kwargs={
|
}), lambda event: eventreverse_absolute(event, 'presale:event.index', kwargs={
|
||||||
'event': event.slug,
|
'event': event.slug,
|
||||||
'organizer': event.organizer.slug,
|
'organizer': event.organizer.slug,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ from django.conf import settings
|
|||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext, gettext_lazy as _
|
||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import CachedFile, Event, User, cachedfile_name
|
from pretix.base.models import CachedFile, Event, User, cachedfile_name
|
||||||
@@ -171,15 +171,16 @@ def shred(self, event: Event, fileid: str, confirm_code: str, user: int=None, lo
|
|||||||
|
|
||||||
if user:
|
if user:
|
||||||
with language(user.locale):
|
with language(user.locale):
|
||||||
|
event_name = str(event.name)
|
||||||
mail(
|
mail(
|
||||||
user.email,
|
user.email,
|
||||||
_('Data shredding completed'),
|
gettext('Data shredding completed for %(event)s') % {'event': event_name},
|
||||||
'pretixbase/email/shred_completed.txt',
|
'pretixbase/email/shred_completed.txt',
|
||||||
{
|
{
|
||||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||||
'user': user,
|
'user': user,
|
||||||
'organizer': event.organizer.name,
|
'organizer': event.organizer.name,
|
||||||
'event': str(event.name),
|
'event': event_name,
|
||||||
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
|
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
|
||||||
'shredders': ', '.join([str(s.verbose_name) for s in shredders])
|
'shredders': ', '.join([str(s.verbose_name) for s in shredders])
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from pretix.base.services.mail import mail
|
|||||||
from pretix.base.settings import GlobalSettingsObject
|
from pretix.base.settings import GlobalSettingsObject
|
||||||
from pretix.base.signals import periodic_task
|
from pretix.base.signals import periodic_task
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.helpers.urls import build_absolute_uri
|
from pretix.helpers.urls import mainreverse_absolute
|
||||||
|
|
||||||
|
|
||||||
@receiver(signal=periodic_task)
|
@receiver(signal=periodic_task)
|
||||||
@@ -121,7 +121,7 @@ def send_update_notification_email():
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
'url': build_absolute_uri('control:global.update')
|
'url': mainreverse_absolute('control:global.update')
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -11,6 +11,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||||
|
<script type="text/javascript" src="{% static "pretixbase/js/errors.js" %}"></script>
|
||||||
{% block custom_header %}{% endblock %}
|
{% block custom_header %}{% endblock %}
|
||||||
{% if css_theme %}
|
{% if css_theme %}
|
||||||
<link rel="stylesheet" type="text/css" href="{{ css_theme }}" />
|
<link rel="stylesheet" type="text/css" href="{{ css_theme }}" />
|
||||||
@@ -21,5 +22,4 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
<script src="{% static "pretixbase/js/errors.js" %}"></script>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% trans "You have requested us to cancel an event which includes a larger bulk-refund:" %}
|
{% trans "You requested to cancel an event that involves a large bulk refund:" %}
|
||||||
|
|
||||||
{% trans "Event" %}: {{ event }}
|
- {% trans "Event" %}: {{ event }}
|
||||||
|
- {% trans "Estimated refund" %}: **{{ amount }}**
|
||||||
|
|
||||||
{% trans "Estimated refund amount" %}: **{{ amount }}**
|
{% trans "To confirm, paste the following code into the cancellation form:" %}
|
||||||
|
|
||||||
{% trans "Please confirm that you want to proceed by coping the following confirmation code into the cancellation form:" %}
|
{{ confirmation_code }}
|
||||||
|
|
||||||
**{{ confirmation_code }}**
|
{% blocktrans with instance=instance %}Don't share this code with anyone. The {{ instance }} team will never ask you for it.{% endblocktrans %}
|
||||||
|
|
||||||
|
{% blocktrans with instance=instance %}Thanks,
|
||||||
|
The {{ instance }} Team{% endblocktrans %}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% trans "Your export failed." %}
|
{% trans "Your scheduled export failed." %}
|
||||||
|
|
||||||
{% trans "Reason:" %} {{ reason }}
|
- {% trans "Reason" %}: {{ reason }}
|
||||||
|
|
||||||
{% if not soft %}
|
{% if not soft %}{% trans "If an export fails five times in a row, we'll stop sending it." %}{% endif %}
|
||||||
{% trans "If your export fails five times in a row, it will no longer be sent." %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% trans "Configuration link:" %}
|
{% trans "You can adjust or remove this export here:" %}
|
||||||
|
|
||||||
{{ configuration_url }}
|
{{ configuration_url }}
|
||||||
|
|
||||||
|
{% blocktrans with instance=instance %}Thanks,
|
||||||
|
The {{ instance }} Team{% endblocktrans %}
|
||||||
|
|||||||
@@ -52,13 +52,15 @@
|
|||||||
<table cellpadding="20"><tr><td>
|
<table cellpadding="20"><tr><td>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{% trans "You receive these emails based on your notification settings." %}<br>
|
{% trans "You're receiving this email based on your notification settings." %}<br>
|
||||||
<a href="{{ settings_url }}">
|
<a href="{{ settings_url }}">
|
||||||
{% trans "Click here to view and change your notification settings" %}
|
{% trans "Manage settings" %}
|
||||||
</a><br>
|
|
||||||
<a href="{{ disable_url }}">
|
|
||||||
{% trans "Click here disable all notifications immediately." %}
|
|
||||||
</a>
|
</a>
|
||||||
|
{% if disable_url %}<br>
|
||||||
|
<a href="{{ disable_url }}">
|
||||||
|
{% trans "Disable all notifications" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<!--[if gte mso 9]>
|
<!--[if gte mso 9]>
|
||||||
</td></tr></table>
|
</td></tr></table>
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{{ notification.title }}{% if notification.detail %}
|
{{ notification.title }}{% if notification.detail %}
|
||||||
|
|
||||||
{{ notification.detail }}
|
{{ notification.detail }}{% endif %}{% if notification.url %}
|
||||||
{% endif %}{% if notification.url %}
|
|
||||||
|
|
||||||
{{ notification.url }}{% endif %}{% for attr in notification.attributes %}
|
{{ notification.url }}{% endif %}{% if notification.attributes %}
|
||||||
|
|
||||||
{{ attr.title }}: {{ attr.value }}{% endfor %}{% for action in notification.actions %}
|
{% for attr in notification.attributes %}- {{ attr.title }}: {{ attr.value }}
|
||||||
|
{% endfor %}{% endif %}{% for action in notification.actions %}
|
||||||
|
|
||||||
{{ action.label }}
|
{{ action.label }}:
|
||||||
{{ action.url }}{% endfor %}
|
|
||||||
|
|
||||||
{% trans "You receive these emails based on your notification settings." %}
|
{{ action.url }}{% endfor %}
|
||||||
{% trans "Click here to view and change your notification settings:" %}
|
|
||||||
{{ settings_url }}
|
---
|
||||||
{% trans "Click here disable all notifications immediately:" %}
|
|
||||||
{{ disable_url }}
|
{% trans "You're receiving this email based on your notification settings." %}
|
||||||
|
|
||||||
|
- {% trans "Manage settings" %}: {{ settings_url }}
|
||||||
|
{% if disable_url %}- {% trans "Disable all notifications" %}: {{ disable_url }}
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}{% blocktrans %}Hello,
|
||||||
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
|
|
||||||
|
|
||||||
we hereby confirm that the following data shredding job has been completed:
|
The following data shredding job has been completed:
|
||||||
|
|
||||||
Organizer: {{ organizer }}
|
- Organizer: {{ organizer }}
|
||||||
|
- Event: {{ event }}
|
||||||
|
- Data selection: {{ shredders }}
|
||||||
|
- Start time: {{ start_time }}
|
||||||
|
|
||||||
Event: {{ event }}
|
Any data added to the event after the start time may not have been deleted.
|
||||||
|
|
||||||
Data selection: {{ shredders }}
|
Thanks,
|
||||||
|
The {{ instance }} Team
|
||||||
Start time: {{ start_time }} (new data added after this time might not have been deleted)
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
|
|
||||||
Your {{ instance }} team
|
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "error.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% trans "Please continue in a new tab" %}</h1>
|
||||||
|
<p class="larger">
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
For security reasons, the following step is only possible in a new tab.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
<p class="larger">
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
If the new tab did not open automatically, please click the following button:
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="{{ url }}"
|
||||||
|
class="btn btn-primary btn-lg" target="_blank">
|
||||||
|
<span class="fa fa-external-link-square"></span>
|
||||||
|
{% trans "Continue in new tab" %}
|
||||||
|
</a>
|
||||||
|
{{ url|json_script:"framebreak-url" }}
|
||||||
|
<script type="text/javascript" src="{% static "pretixbase/js/framebreak.js" %}"></script>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load rich_text %}
|
{% load rich_text %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load wrap_in %}
|
||||||
{% block title %}{% trans "Redirect" %}{% endblock %}
|
{% block title %}{% trans "Redirect" %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<i class="fa fa-link fa-fw big-icon"></i>
|
<i class="fa fa-link fa-fw big-icon"></i>
|
||||||
<div class="error-details">
|
<div class="error-details">
|
||||||
<h1>{% trans "Redirect" %}</h1>
|
<h1>{% trans "Redirect" %}</h1>
|
||||||
<h3>
|
<h3>
|
||||||
{% blocktrans trimmed with host="<strong>"|add:hostname|add:"</strong>"|safe %}
|
{% blocktrans trimmed with host=hostname|wrap_in:'strong' %}
|
||||||
The link you clicked on wants to redirect you to a destination on the website {{ host }}.
|
The link you clicked on wants to redirect you to a destination on the website {{ host }}.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
{% blocktrans trimmed %}
|
{% blocktrans trimmed %}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from django import template
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def human_readable_locale(value):
|
||||||
|
if not value:
|
||||||
|
return ''
|
||||||
|
return dict(settings.LANGUAGES).get(value, '')
|
||||||
@@ -42,8 +42,6 @@ from bleach import DEFAULT_CALLBACKS, html5lib_shim
|
|||||||
from bleach.linkifier import build_email_re
|
from bleach.linkifier import build_email_re
|
||||||
from django import template
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.functional import SimpleLazyObject
|
from django.utils.functional import SimpleLazyObject
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
@@ -54,6 +52,7 @@ from markdown.postprocessors import Postprocessor
|
|||||||
from markdown.treeprocessors import UnescapeTreeprocessor
|
from markdown.treeprocessors import UnescapeTreeprocessor
|
||||||
from tlds import tld_set
|
from tlds import tld_set
|
||||||
|
|
||||||
|
from pretix.base.views.redirect import safelink
|
||||||
from pretix.helpers.format import SafeFormatter, format_map
|
from pretix.helpers.format import SafeFormatter, format_map
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
@@ -158,8 +157,7 @@ def safelink_callback(attrs, new=False):
|
|||||||
"""
|
"""
|
||||||
url = html.unescape(attrs.get((None, 'href'), '/'))
|
url = html.unescape(attrs.get((None, 'href'), '/'))
|
||||||
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
|
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
|
||||||
signer = signing.Signer(salt='safe-redirect')
|
attrs[None, 'href'] = safelink(url)
|
||||||
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
|
||||||
attrs[None, 'target'] = '_blank'
|
attrs[None, 'target'] = '_blank'
|
||||||
attrs[None, 'rel'] = 'noopener'
|
attrs[None, 'rel'] = 'noopener'
|
||||||
return attrs
|
return attrs
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
#
|
||||||
|
# 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/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
import importlib_metadata as metadata
|
||||||
|
from django import template
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
_MANIFEST = {}
|
||||||
|
# TODO more os.path.join ?
|
||||||
|
MANIFEST_PATH = settings.STATIC_ROOT + "/vite/control/.vite/manifest.json"
|
||||||
|
MANIFEST_BASE = "vite/control/"
|
||||||
|
|
||||||
|
# entry_name -> {"manifest_entry": {...}, "url_base": "..."}
|
||||||
|
_PLUGIN_REGISTRY = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_plugin_manifests():
|
||||||
|
"""Discover plugin vite manifests at startup.
|
||||||
|
|
||||||
|
Scans installed pretix plugins for a .vite/manifest.json inside a static.dist
|
||||||
|
directory. Only non-editable (wheel) plugins are expected to ship pre-built
|
||||||
|
assets; editable plugins are served through the Vite dev server.
|
||||||
|
"""
|
||||||
|
for ep in metadata.entry_points(group='pretix.plugin'):
|
||||||
|
dist = ep.dist
|
||||||
|
if not dist or not dist.files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
url_info = json.loads(dist.read_text('direct_url.json') or '{}')
|
||||||
|
if url_info.get('dir_info', {}).get('editable', False):
|
||||||
|
continue # editable plugins are served via vite dev server
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Find .vite/manifest.json inside a /static/ directory
|
||||||
|
try:
|
||||||
|
manifest_rel = None
|
||||||
|
for f in dist.files:
|
||||||
|
if f.name == 'manifest.json' and '/static/' in str(f) and '/.vite/' in str(f):
|
||||||
|
manifest_rel = f
|
||||||
|
break
|
||||||
|
|
||||||
|
if not manifest_rel:
|
||||||
|
continue
|
||||||
|
|
||||||
|
manifest_path = pathlib.Path(str(dist.locate_file(manifest_rel)))
|
||||||
|
if not manifest_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
plugin_manifest = json.loads(manifest_path.read_text())
|
||||||
|
|
||||||
|
url_base = re.search(r'/static/(.+?)/\.vite/', str(manifest_rel)).group(1) + '/'
|
||||||
|
|
||||||
|
for _key, entry in plugin_manifest.items():
|
||||||
|
if entry.get('isEntry') and 'name' in entry:
|
||||||
|
_PLUGIN_REGISTRY[entry['name']] = {
|
||||||
|
'manifest_entry': entry,
|
||||||
|
'url_base': url_base,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
LOGGER.warning(f"Failed to discover vite manifest for plugin {ep.name}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Load core manifest
|
||||||
|
if not settings.VITE_DEV_MODE and not settings.VITE_IGNORE:
|
||||||
|
try:
|
||||||
|
with open(MANIFEST_PATH) as fp:
|
||||||
|
_MANIFEST = json.load(fp)
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.warning(f"Error reading vite manifest at {MANIFEST_PATH}: {str(e)}")
|
||||||
|
|
||||||
|
# Discover plugin manifests
|
||||||
|
if not settings.VITE_IGNORE:
|
||||||
|
_discover_plugin_manifests()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_script_tag(path, attrs, src=None):
|
||||||
|
all_attrs = " ".join(f'{key}="{value}"' for key, value in attrs.items())
|
||||||
|
if src is None:
|
||||||
|
if settings.VITE_DEV_MODE:
|
||||||
|
src = urljoin(settings.VITE_DEV_SERVER, path)
|
||||||
|
else:
|
||||||
|
src = urljoin(settings.STATIC_URL, path)
|
||||||
|
return f'<script {all_attrs} src="{src}"></script>'
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_css_tags(asset, already_processed=None):
|
||||||
|
"""Recursively builds all CSS tags used in a given asset from the core manifest."""
|
||||||
|
tags = []
|
||||||
|
manifest_entry = _MANIFEST[asset]
|
||||||
|
if already_processed is None:
|
||||||
|
already_processed = []
|
||||||
|
|
||||||
|
if "css" in manifest_entry:
|
||||||
|
for css_path in manifest_entry["css"]:
|
||||||
|
if css_path not in already_processed:
|
||||||
|
full_path = urljoin(settings.STATIC_URL, MANIFEST_BASE + css_path)
|
||||||
|
tags.append(f'<link rel="stylesheet" href="{full_path}" />')
|
||||||
|
already_processed.append(css_path)
|
||||||
|
|
||||||
|
if "imports" in manifest_entry:
|
||||||
|
for import_path in manifest_entry["imports"]:
|
||||||
|
tags += _generate_css_tags(import_path, already_processed)
|
||||||
|
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_plugin_css_tags(manifest_entry, url_base):
|
||||||
|
"""Build CSS tags for a plugin manifest entry."""
|
||||||
|
tags = []
|
||||||
|
if "css" in manifest_entry:
|
||||||
|
for css_path in manifest_entry["css"]:
|
||||||
|
full_path = urljoin(settings.STATIC_URL, url_base + css_path)
|
||||||
|
tags.append(f'<link rel="stylesheet" href="{full_path}" />')
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
@mark_safe
|
||||||
|
def vite_asset(path):
|
||||||
|
"""
|
||||||
|
Generates one <script> tag and <link> tags for each of the CSS dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Check plugin registry (non-editable plugins with pre-built assets)
|
||||||
|
if path in _PLUGIN_REGISTRY:
|
||||||
|
info = _PLUGIN_REGISTRY[path]
|
||||||
|
entry = info['manifest_entry']
|
||||||
|
url_base = info['url_base']
|
||||||
|
tags = _generate_plugin_css_tags(entry, url_base)
|
||||||
|
# Always use STATIC_URL for pre-built plugin assets, even in dev mode
|
||||||
|
src = urljoin(settings.STATIC_URL, url_base + entry["file"])
|
||||||
|
tags.append(_generate_script_tag(path, {"type": "module", "crossorigin": ""}, src=src))
|
||||||
|
return "".join(tags)
|
||||||
|
|
||||||
|
# Dev mode: editable plugins and core entries go through the vite dev server
|
||||||
|
if settings.VITE_DEV_MODE:
|
||||||
|
return _generate_script_tag(path, {"type": "module"})
|
||||||
|
|
||||||
|
# Prod mode
|
||||||
|
manifest_entry = _MANIFEST.get(path)
|
||||||
|
if not manifest_entry:
|
||||||
|
raise RuntimeError(f"Cannot find {path} in Vite manifest at {MANIFEST_PATH}")
|
||||||
|
|
||||||
|
tags = _generate_css_tags(path)
|
||||||
|
tags.append(
|
||||||
|
_generate_script_tag(
|
||||||
|
MANIFEST_BASE + manifest_entry["file"], {"type": "module", "crossorigin": ""}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return "".join(tags)
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
@mark_safe
|
||||||
|
def vite_hmr():
|
||||||
|
if not settings.VITE_DEV_MODE:
|
||||||
|
return ""
|
||||||
|
return _generate_script_tag("@vite/client", {"type": "module"})
|
||||||
|
|
||||||
|
|
||||||
|
_dev_importmap_cache = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dev_importmap():
|
||||||
|
"""Fetch the shared-dep import map from the Vite dev server. Cached after first call."""
|
||||||
|
global _dev_importmap_cache
|
||||||
|
if _dev_importmap_cache is not None:
|
||||||
|
return _dev_importmap_cache
|
||||||
|
try:
|
||||||
|
url = urljoin(settings.VITE_DEV_SERVER, "/__pretix_importmap")
|
||||||
|
raw = json.loads(urlopen(url, timeout=2).read())
|
||||||
|
_dev_importmap_cache = {
|
||||||
|
dep: urljoin(settings.VITE_DEV_SERVER, dep_path)
|
||||||
|
for dep, dep_path in raw.items()
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
LOGGER.warning("Failed to fetch import map from Vite dev server")
|
||||||
|
_dev_importmap_cache = {}
|
||||||
|
return _dev_importmap_cache
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
@mark_safe
|
||||||
|
def vite_importmap(context):
|
||||||
|
"""Emit an import map so pre-built plugin assets can resolve shared dependencies like vue."""
|
||||||
|
imports = {}
|
||||||
|
|
||||||
|
if settings.VITE_DEV_MODE:
|
||||||
|
# Fetch the import map from the Vite dev server (served by sharedDepsPlugin)
|
||||||
|
imports.update(_get_dev_importmap())
|
||||||
|
else:
|
||||||
|
# Discover all _vendor/* entries from the core manifest
|
||||||
|
for _key, entry in _MANIFEST.items():
|
||||||
|
name = entry.get("name", "")
|
||||||
|
if name.startswith("_vendor/"):
|
||||||
|
bare_specifier = name[len("_vendor/"):]
|
||||||
|
imports[bare_specifier] = urljoin(settings.STATIC_URL, MANIFEST_BASE + entry["file"])
|
||||||
|
|
||||||
|
if not imports:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Generate a nonce and store it on the request so the CSP middleware can allow it
|
||||||
|
nonce = secrets.token_urlsafe(16)
|
||||||
|
request = context.get('request')
|
||||||
|
if request:
|
||||||
|
request.csp_nonce = nonce
|
||||||
|
|
||||||
|
return f'<script type="importmap" nonce="{nonce}">{json.dumps({"imports": imports})}</script>'
|
||||||
@@ -24,10 +24,12 @@ import calendar
|
|||||||
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rrulestr
|
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rrulestr
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import RegexValidator, validate_email
|
||||||
from django.utils.deconstruct import deconstructible
|
from django.utils.deconstruct import deconstructible
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from pretix.base.templatetags.rich_text import URL_RE
|
||||||
|
|
||||||
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
||||||
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||||
#
|
#
|
||||||
@@ -113,6 +115,33 @@ def multimail_validate(val):
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
class RegexValidatorInverseMatchAndParam(RegexValidator):
|
||||||
|
inverse_match = True
|
||||||
|
|
||||||
|
def __call__(self, value):
|
||||||
|
regex_matches = self.regex.search(str(value))
|
||||||
|
if regex_matches:
|
||||||
|
raise ValidationError(
|
||||||
|
self.message,
|
||||||
|
code=self.code,
|
||||||
|
params={
|
||||||
|
"value": value,
|
||||||
|
"match": regex_matches.group(0) if regex_matches else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NoUrlValidator(RegexValidatorInverseMatchAndParam):
|
||||||
|
regex = URL_RE
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
if not kwargs.get("message"):
|
||||||
|
kwargs["message"] = _('You entered an URL, which is not allowed. Please remove %(match)s from your input.')
|
||||||
|
if not kwargs.get("code"):
|
||||||
|
kwargs["code"] = "contains_url"
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class RRuleValidator:
|
class RRuleValidator:
|
||||||
def __init__(self, enforce_simple=False):
|
def __init__(self, enforce_simple=False):
|
||||||
self.enforce_simple = enforce_simple
|
self.enforce_simple = enforce_simple
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import logging
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
@@ -26,6 +27,8 @@ from django.http import HttpResponseBadRequest, HttpResponseRedirect
|
|||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _is_samesite_referer(request):
|
def _is_samesite_referer(request):
|
||||||
referer = request.headers.get('referer')
|
referer = request.headers.get('referer')
|
||||||
@@ -42,11 +45,16 @@ def _is_samesite_referer(request):
|
|||||||
|
|
||||||
|
|
||||||
def redir_view(request):
|
def redir_view(request):
|
||||||
signer = signing.Signer(salt='safe-redirect')
|
framebreak = "framebreak" in request.GET
|
||||||
|
salt = 'framebreak-safelink-url' if framebreak else 'safelink-url'
|
||||||
try:
|
try:
|
||||||
url = signer.unsign(request.GET.get('url', ''))
|
url = signing.Signer(salt=salt).unsign(request.GET.get('url', ''))
|
||||||
except signing.BadSignature:
|
except signing.BadSignature:
|
||||||
return HttpResponseBadRequest('Invalid parameter')
|
try:
|
||||||
|
# Backwards-compatibility for a change in 2026-06, remove after a while
|
||||||
|
url = signing.Signer(salt='safe-redirect').unsign(request.GET.get('url', ''))
|
||||||
|
except signing.BadSignature:
|
||||||
|
return HttpResponseBadRequest('Invalid parameter')
|
||||||
|
|
||||||
if not _is_samesite_referer(request):
|
if not _is_samesite_referer(request):
|
||||||
u = urllib.parse.urlparse(url)
|
u = urllib.parse.urlparse(url)
|
||||||
@@ -55,11 +63,26 @@ def redir_view(request):
|
|||||||
'url': url,
|
'url': url,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if framebreak:
|
||||||
|
r = render(request, 'pretixbase/framebreak.html', {
|
||||||
|
'url': url,
|
||||||
|
})
|
||||||
|
r.xframe_options_exempt = True
|
||||||
|
return r
|
||||||
|
|
||||||
r = HttpResponseRedirect(url)
|
r = HttpResponseRedirect(url)
|
||||||
r['X-Robots-Tag'] = 'noindex'
|
r['X-Robots-Tag'] = 'noindex'
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
def safelink(url):
|
def safelink(url, framebreak=False):
|
||||||
signer = signing.Signer(salt='safe-redirect')
|
url = str(url)
|
||||||
return reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
if not (url.startswith('https://') or url.startswith('http://') or url.startswith("/")):
|
||||||
|
logger.warning('Invalid URL passed to safelink: %r', url)
|
||||||
|
return '#invalid-url'
|
||||||
|
salt = 'framebreak-safelink-url' if framebreak else 'safelink-url'
|
||||||
|
signer = signing.Signer(salt=salt)
|
||||||
|
u = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
||||||
|
if framebreak:
|
||||||
|
u += "&framebreak=true"
|
||||||
|
return u
|
||||||
|
|||||||
@@ -19,14 +19,56 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery, signals
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
app = Celery('pretix')
|
app = Celery('pretix')
|
||||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||||
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
|
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(signals.before_task_publish)
|
||||||
|
def on_before_task_publish(sender, body, exchange, routing_key, headers, properties, declare, retry_policy, **kwargs):
|
||||||
|
from pretix.helpers.logs import local
|
||||||
|
|
||||||
|
trace = getattr(local, 'trace', [])
|
||||||
|
request_id = getattr(local, 'request_id', None)
|
||||||
|
if request_id:
|
||||||
|
trace.append(request_id)
|
||||||
|
|
||||||
|
headers["X-Pretix-Trace"] = " ".join(trace)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(signals.task_received)
|
||||||
|
def on_task_received(sender, request, **kwargs):
|
||||||
|
trace = request._request_dict.get("X-Pretix-Trace")
|
||||||
|
if trace:
|
||||||
|
logger.info(f"Task {request.id} has trace {trace}")
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(signals.task_prerun)
|
||||||
|
def on_task_prerun(sender, task_id, task, **kwargs):
|
||||||
|
from pretix.helpers.logs import local
|
||||||
|
|
||||||
|
local.request_id = task_id
|
||||||
|
if "X-Pretix-Trace" in task.request.headers:
|
||||||
|
local.trace = task.request.headers["X-Pretix-Trace"].split(" ")
|
||||||
|
else:
|
||||||
|
local.trace = []
|
||||||
|
local.trace.append(task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(signals.task_postrun)
|
||||||
|
def on_task_postrun(sender, task_id, task, **kwargs):
|
||||||
|
from pretix.helpers.logs import local
|
||||||
|
|
||||||
|
local.request_id = None
|
||||||
|
local.trace = []
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user