mirror of
https://github.com/pretix/pretix.git
synced 2025-12-06 21:42:49 +00:00
Compare commits
396 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5007e4bd6 | ||
|
|
fb3046210b | ||
|
|
37908bd042 | ||
|
|
74bcbe8f07 | ||
|
|
29f378c58b | ||
|
|
9b537aeb5c | ||
|
|
4b5cd35a0e | ||
|
|
e0675233d5 | ||
|
|
e9726a5227 | ||
|
|
78b65d0757 | ||
|
|
ef620ceb37 | ||
|
|
8575a5f1cd | ||
|
|
5762ffc035 | ||
|
|
79286bb051 | ||
|
|
05a2f411db | ||
|
|
e93e5c047c | ||
|
|
2619a658c9 | ||
|
|
808775c76b | ||
|
|
9f297fbd25 | ||
|
|
d882da0adb | ||
|
|
73bd4a746e | ||
|
|
bc5d0763f3 | ||
|
|
ff084f04b1 | ||
|
|
71af40a08b | ||
|
|
ef60093bae | ||
|
|
49c4cc639f | ||
|
|
e2800019f6 | ||
|
|
c44ea8aa81 | ||
|
|
47a03e1b2a | ||
|
|
86ddca15ca | ||
|
|
294b3966b0 | ||
|
|
fecc00231b | ||
|
|
b3dfc459f5 | ||
|
|
e21d63a7be | ||
|
|
9a807df158 | ||
|
|
e95d551711 | ||
|
|
7188e44fe5 | ||
|
|
a6a93555b6 | ||
|
|
94eb473e42 | ||
|
|
cbc3a344c1 | ||
|
|
47db52d75f | ||
|
|
ba99fe597c | ||
|
|
b638c00952 | ||
|
|
bfcca7046a | ||
|
|
ad5d10ff67 | ||
|
|
54d327deea | ||
|
|
d6505f946f | ||
|
|
9c4efa7dcf | ||
|
|
e6d26c4962 | ||
|
|
7ddbbe21f7 | ||
|
|
8d5ad0bd9e | ||
|
|
aff6a6f022 | ||
|
|
46008818ce | ||
|
|
95db04bad2 | ||
|
|
d0c62ec1cf | ||
|
|
d6cbb130bd | ||
|
|
097d2fcda0 | ||
|
|
41a7c13970 | ||
|
|
1b725810dd | ||
|
|
251f486480 | ||
|
|
a7afcdf753 | ||
|
|
0722341073 | ||
|
|
207bf101b8 | ||
|
|
e8f7cea1bf | ||
|
|
aa55eb2de2 | ||
|
|
9dc5c1b266 | ||
|
|
514f1def4d | ||
|
|
c2bc97a0d8 | ||
|
|
7fba473426 | ||
|
|
be87ba0000 | ||
|
|
76b7643c39 | ||
|
|
b1a3963b33 | ||
|
|
586e694ff3 | ||
|
|
f4383c67a4 | ||
|
|
46b2214836 | ||
|
|
0e20d897d2 | ||
|
|
0c09cccd4f | ||
|
|
5ca0833db1 | ||
|
|
7a63498333 | ||
|
|
b8c0887f79 | ||
|
|
9da65f60d7 | ||
|
|
806124304a | ||
|
|
0d57673a47 | ||
|
|
166b5e4f3b | ||
|
|
541b8f5bd6 | ||
|
|
d2b96b2425 | ||
|
|
04d4c4f8f1 | ||
|
|
f4da94cbcd | ||
|
|
97e3d5387f | ||
|
|
8fc07523a9 | ||
|
|
f18b0ae187 | ||
|
|
f7e16f56ac | ||
|
|
3f4e869cea | ||
|
|
8c2a1d58f4 | ||
|
|
0b05eb34f4 | ||
|
|
be48c5f94c | ||
|
|
cebb6d3b43 | ||
|
|
0de96ed066 | ||
|
|
a9d506b1fa | ||
|
|
7a01057429 | ||
|
|
64e1a602d6 | ||
|
|
fe060c387a | ||
|
|
1dba4c7cc9 | ||
|
|
20b2a3d2aa | ||
|
|
044f0c5480 | ||
|
|
4d394f9e8a | ||
|
|
247c4c6c9c | ||
|
|
11a038feb3 | ||
|
|
9d57ea8534 | ||
|
|
189c77207f | ||
|
|
3422003a9c | ||
|
|
8da38ba99d | ||
|
|
fc05208b92 | ||
|
|
b163109c56 | ||
|
|
3ba818336e | ||
|
|
8aecf4f98f | ||
|
|
42f3ca9661 | ||
|
|
f7b405b210 | ||
|
|
11a6390cfc | ||
|
|
239a7746df | ||
|
|
03701eaa82 | ||
|
|
356f215d8e | ||
|
|
7962c4e380 | ||
|
|
a59711ed32 | ||
|
|
49370a5e08 | ||
|
|
980aec7326 | ||
|
|
44294110fe | ||
|
|
ce1078a783 | ||
|
|
0e0cede0ee | ||
|
|
5c833cd493 | ||
|
|
64d6a34039 | ||
|
|
cf380069b4 | ||
|
|
48168a4c68 | ||
|
|
6482fe79b0 | ||
|
|
0f696f42f6 | ||
|
|
79d59553d7 | ||
|
|
cc903c39f0 | ||
|
|
b6a42ac8d2 | ||
|
|
5f5001edb5 | ||
|
|
fb403dad88 | ||
|
|
a73c8f580d | ||
|
|
f490c89e98 | ||
|
|
159658ae46 | ||
|
|
595aff0579 | ||
|
|
b2842ec3a0 | ||
|
|
f09f07ec7c | ||
|
|
cff073f0d6 | ||
|
|
eb9d0c6cf9 | ||
|
|
e263946c3f | ||
|
|
7ee957cff0 | ||
|
|
ac2fe4b62d | ||
|
|
577e276df3 | ||
|
|
11956a8f4d | ||
|
|
d0c58713c4 | ||
|
|
46da0bda61 | ||
|
|
009e3a6d36 | ||
|
|
c01855270b | ||
|
|
2df1585b71 | ||
|
|
bf48ae567f | ||
|
|
5a72c72d18 | ||
|
|
ac02f3b417 | ||
|
|
58add74b3a | ||
|
|
0067c3537d | ||
|
|
64ae1d08a6 | ||
|
|
ca25c3c81e | ||
|
|
abbe9ec897 | ||
|
|
a7735d5d9e | ||
|
|
174c81a22b | ||
|
|
38c6294ede | ||
|
|
217ae90642 | ||
|
|
0c998ca884 | ||
|
|
b1691f867d | ||
|
|
72e451b27b | ||
|
|
8124ced6c1 | ||
|
|
a3139944f6 | ||
|
|
48493c517b | ||
|
|
535a29bf4b | ||
|
|
9d415f5179 | ||
|
|
990e9da21d | ||
|
|
4afb7a4976 | ||
|
|
22e5579ed1 | ||
|
|
79cd84e243 | ||
|
|
fad4b8846c | ||
|
|
0f4790afd8 | ||
|
|
2068a5ac29 | ||
|
|
440c97061c | ||
|
|
3b6d0c4341 | ||
|
|
06ac4b0250 | ||
|
|
a233b92f6f | ||
|
|
4ea4189e6d | ||
|
|
50838b9cea | ||
|
|
c68ee56d51 | ||
|
|
5c0587c30e | ||
|
|
f3f42a8a42 | ||
|
|
20d0a9a0ed | ||
|
|
cda8144ff0 | ||
|
|
43e8875c1e | ||
|
|
28c142b2ed | ||
|
|
46203fd8ba | ||
|
|
52e45c37df | ||
|
|
d1580dca2c | ||
|
|
cd9e672871 | ||
|
|
427f508627 | ||
|
|
887d06a485 | ||
|
|
fb49046ac1 | ||
|
|
ce826e50f7 | ||
|
|
d866c6954d | ||
|
|
40c76dda74 | ||
|
|
f532853021 | ||
|
|
8cb187502d | ||
|
|
156037f2cd | ||
|
|
134d63fb3f | ||
|
|
816002fda0 | ||
|
|
3939bbc11c | ||
|
|
95d1603cc7 | ||
|
|
ada3ada699 | ||
|
|
97eaeac4f2 | ||
|
|
d67f5c650c | ||
|
|
273c1ae0a6 | ||
|
|
a946c10ab4 | ||
|
|
2d8fba7d7c | ||
|
|
e4e0bd7ca0 | ||
|
|
3651c88289 | ||
|
|
c92ca40026 | ||
|
|
4d00efb549 | ||
|
|
7e60d13910 | ||
|
|
35800e21c7 | ||
|
|
99b4c5bd36 | ||
|
|
f121205dd1 | ||
|
|
1ac54cd209 | ||
|
|
4694719a53 | ||
|
|
9513b6e8d7 | ||
|
|
4fd7d406a0 | ||
|
|
47cb5b207a | ||
|
|
7d2cf68727 | ||
|
|
459cb47ca8 | ||
|
|
39705556cd | ||
|
|
9f794290dc | ||
|
|
b6221ab6d9 | ||
|
|
483518bce9 | ||
|
|
d9019ae735 | ||
|
|
721fd3b998 | ||
|
|
ad0d3f5469 | ||
|
|
40b44f9272 | ||
|
|
304d290f22 | ||
|
|
7592a8a575 | ||
|
|
4f33159f93 | ||
|
|
819ce6abf7 | ||
|
|
760dfd22b8 | ||
|
|
f9eaa193c9 | ||
|
|
c7720a2553 | ||
|
|
7754f5420c | ||
|
|
2c7ada6e86 | ||
|
|
3f31843fd1 | ||
|
|
952b9bd9b9 | ||
|
|
3619a6bcd0 | ||
|
|
3e2c12cdb0 | ||
|
|
a3ce3b9af3 | ||
|
|
b6461e9303 | ||
|
|
f7dfd51c2c | ||
|
|
3b98d87a26 | ||
|
|
f045062055 | ||
|
|
eb501dd1ea | ||
|
|
2d8793c355 | ||
|
|
d32bd717b7 | ||
|
|
6e6b75d55e | ||
|
|
50b5f760bb | ||
|
|
9ab2e61c31 | ||
|
|
4876a0b61f | ||
|
|
56bbcb65c3 | ||
|
|
5bb1cb498f | ||
|
|
6bf23b0fdd | ||
|
|
5deb1a8c69 | ||
|
|
1523137300 | ||
|
|
04ef097eb1 | ||
|
|
a5d4434a64 | ||
|
|
3b3c668153 | ||
|
|
d339d67111 | ||
|
|
a8aee6c824 | ||
|
|
6466987493 | ||
|
|
ff962805cd | ||
|
|
2b5f46164f | ||
|
|
d74451ded1 | ||
|
|
62f0c82d8d | ||
|
|
5b587774bb | ||
|
|
88ea8ee2ea | ||
|
|
56e0ab8378 | ||
|
|
a9ae237b1a | ||
|
|
27823b7bf6 | ||
|
|
4231cd2576 | ||
|
|
6aa5196f18 | ||
|
|
c1eac5e91e | ||
|
|
410e06364a | ||
|
|
ae137f8f16 | ||
|
|
f9f3f9f868 | ||
|
|
395eadde47 | ||
|
|
2be790fa45 | ||
|
|
f9d78eaf1a | ||
|
|
2d5d27e950 | ||
|
|
c6fa19d771 | ||
|
|
3129253eef | ||
|
|
b69ab86458 | ||
|
|
80f7ae0b76 | ||
|
|
160f9a4363 | ||
|
|
24b5b9373d | ||
|
|
178c40aee6 | ||
|
|
49c41878d2 | ||
|
|
fa4c29cf23 | ||
|
|
75b93eebc5 | ||
|
|
5a406abdd6 | ||
|
|
6712baf534 | ||
|
|
4d9243151f | ||
|
|
b89a4f7b32 | ||
|
|
c80d5b1bb2 | ||
|
|
0334c2f433 | ||
|
|
6bc46b7aec | ||
|
|
3ebe622189 | ||
|
|
25fb1ee3be | ||
|
|
a3586a73f1 | ||
|
|
93eb041acc | ||
|
|
63894ca3da | ||
|
|
73b2cce435 | ||
|
|
0a711f4965 | ||
|
|
75bf200aac | ||
|
|
11307de30a | ||
|
|
863db60786 | ||
|
|
f5a1adedca | ||
|
|
ea74688633 | ||
|
|
57738f19bf | ||
|
|
7b5ce5e198 | ||
|
|
d5f9beef69 | ||
|
|
eee39b1300 | ||
|
|
c2fdea020d | ||
|
|
f87e089734 | ||
|
|
0fad7472c0 | ||
|
|
bd0a223066 | ||
|
|
782c1a5d39 | ||
|
|
5c99d3bf69 | ||
|
|
5e6307acc9 | ||
|
|
d4bfa9d773 | ||
|
|
b7f540251c | ||
|
|
cb17d80b63 | ||
|
|
86b28b9b53 | ||
|
|
fac404631c | ||
|
|
fd547014a9 | ||
|
|
d23a625415 | ||
|
|
199416b904 | ||
|
|
0a7a113b4e | ||
|
|
5978a715b5 | ||
|
|
71beb54eb4 | ||
|
|
b92981353f | ||
|
|
a5f7115e19 | ||
|
|
f5e3d4b0bc | ||
|
|
6fba080b8f | ||
|
|
365ccf159e | ||
|
|
b40a41d742 | ||
|
|
bf1081071b | ||
|
|
e261ce7554 | ||
|
|
7cae0ceab8 | ||
|
|
064ee91225 | ||
|
|
495b3ec770 | ||
|
|
39f9329207 | ||
|
|
2b72cfdaff | ||
|
|
70d32ea1aa | ||
|
|
0871482681 | ||
|
|
5d4f3eab06 | ||
|
|
fa3265b1fb | ||
|
|
92e6ffc7ef | ||
|
|
22f91f7aa2 | ||
|
|
43facd1e43 | ||
|
|
11242a2325 | ||
|
|
28db9a5262 | ||
|
|
57e8c6aafd | ||
|
|
fa47f63307 | ||
|
|
bac673f3ab | ||
|
|
45ac391998 | ||
|
|
fed5097708 | ||
|
|
9d115c30d7 | ||
|
|
a769da62c7 | ||
|
|
e20edab98f | ||
|
|
4f4fcb84ce | ||
|
|
6560d161c9 | ||
|
|
7f23c590ca | ||
|
|
0ca33eddb1 | ||
|
|
45341c4a31 | ||
|
|
5de5ae4ca2 | ||
|
|
03f71f3cdf | ||
|
|
f97ad66026 | ||
|
|
31392e5852 | ||
|
|
77a5a685f1 | ||
|
|
2b77e59e0a | ||
|
|
70f755599a | ||
|
|
2a76b2a5dd | ||
|
|
ffea243eae | ||
|
|
a4012e6100 | ||
|
|
9bd250f9fc |
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -10,7 +10,9 @@ updates:
|
||||
schedule:
|
||||
interval: "daily"
|
||||
versioning-strategy: increase
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/src/pretix/static/npm_dir"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -26,12 +26,12 @@ jobs:
|
||||
matrix:
|
||||
python-version: ["3.11"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
|
||||
6
.github/workflows/docs.yml
vendored
6
.github/workflows/docs.yml
vendored
@@ -25,12 +25,12 @@ jobs:
|
||||
name: Spellcheck
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
|
||||
12
.github/workflows/strings.yml
vendored
12
.github/workflows/strings.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
name: Check gettext syntax
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
@@ -48,12 +48,12 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
name: Spellcheck
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
|
||||
16
.github/workflows/style.yml
vendored
16
.github/workflows/style.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
||||
name: isort
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
@@ -43,12 +43,12 @@ jobs:
|
||||
name: flake8
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
@@ -63,9 +63,9 @@ jobs:
|
||||
name: licenseheaders
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- name: Install Dependencies
|
||||
|
||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
- database: sqlite
|
||||
python-version: "3.10"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: harmon758/postgresql-action@v1
|
||||
with:
|
||||
postgresql version: '15'
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
postgresql password: 'postgres'
|
||||
if: matrix.database == 'postgres'
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
|
||||
@@ -52,10 +52,18 @@ Example::
|
||||
``currency``
|
||||
The default currency as a three-letter code. Defaults to ``EUR``.
|
||||
|
||||
``cachedir``
|
||||
The local path to a directory where temporary files will be stored.
|
||||
Defaults to the ``cache`` directory below the ``datadir``.
|
||||
|
||||
``datadir``
|
||||
The local path to a data directory that will be used for storing user uploads and similar
|
||||
data. Defaults to the value of the environment variable ``DATA_DIR`` or ``data``.
|
||||
|
||||
``logdir``
|
||||
The local path to a directory where log files will be stored.
|
||||
Defaults to the ``logs`` directory below the ``datadir``.
|
||||
|
||||
``plugins_default``
|
||||
A comma-separated list of plugins that are enabled by default for all new events.
|
||||
Defaults to ``pretix.plugins.sendmail,pretix.plugins.statistics``.
|
||||
@@ -89,8 +97,9 @@ Example::
|
||||
Defaults to ``off``.
|
||||
|
||||
``obligatory_2fa``
|
||||
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
|
||||
Defaults to ``False``
|
||||
Enables or disables obligatory usage of two-factor authentication for users of the pretix backend.
|
||||
Can be ``True`` to make two-factor authentication obligatory for all users or ``staff`` to make it only
|
||||
obligatory to users with admin permissions. Defaults to ``False``.
|
||||
|
||||
``trust_x_forwarded_for``
|
||||
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
|
||||
@@ -149,6 +158,7 @@ Example::
|
||||
host=localhost
|
||||
port=3306
|
||||
advisory_lock_index=1
|
||||
disable_server_side_cursors=0
|
||||
sslmode=require
|
||||
sslrootcert=/etc/pretix/postgresql-ca.crt
|
||||
sslcert=/etc/pretix/postgresql-client-crt.crt
|
||||
@@ -169,6 +179,11 @@ Example::
|
||||
and are not scoped to a specific database. If you run multiple pretix applications with the same PostgreSQL server,
|
||||
you should set separate values for this setting (integers up to 256).
|
||||
|
||||
``disable_server_side_cursors``
|
||||
On PostgreSQL pretix might use server side cursors for certain operations. This is generally fine but will break in
|
||||
specific circumstances, for example when connecting to PostgreSQL through a PGBouncer configured with a transaction
|
||||
pool mode. Off by default (i.e. by default server side cursors will be used).
|
||||
|
||||
``sslmode``, ``sslrootcert``
|
||||
Connection TLS details for the PostgreSQL database connection. Possible values of ``sslmode`` are ``disable``, ``allow``, ``prefer``, ``require``, ``verify-ca``, and ``verify-full``. ``sslrootcert`` should be the accessible path of the ca certificate. Both values are empty by default.
|
||||
|
||||
@@ -345,7 +360,7 @@ to speed up various operations::
|
||||
The location of redis, as a URL of the form ``redis://[:password]@localhost:6379/0``
|
||||
or ``unix://[:password]@/path/to/socket.sock?db=0``
|
||||
|
||||
``session``
|
||||
``sessions``
|
||||
When this is set to ``True``, redis will be used as the session storage.
|
||||
|
||||
``sentinels``
|
||||
@@ -521,4 +536,4 @@ pretix can optionally make use of a GeoIP database for some features. It needs a
|
||||
|
||||
|
||||
.. _GeoAcumen: https://github.com/geoacumen/geoacumen-country
|
||||
.. _GeoLite2: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
|
||||
.. _GeoLite2: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
|
||||
|
||||
18
doc/admin/installation/community.rst
Normal file
18
doc/admin/installation/community.rst
Normal file
@@ -0,0 +1,18 @@
|
||||
.. highlight:: none
|
||||
|
||||
.. _`community`:
|
||||
|
||||
Community install guides
|
||||
========================
|
||||
|
||||
.. warning:: The guides are maintained by the community and not by the pretix core team. If you encounter any issues with the guides, please report them to the maintainers of the guides. The pretix core team can not provide support for installs using these guides.
|
||||
|
||||
Kubernetes
|
||||
----------
|
||||
|
||||
- Helm Chart by techwolf12 - A Helm chart for deploying pretix on Kubernetes. The chart documentation is available on `ArtifactHub <https://artifacthub.io/packages/helm/techwolf12/pretix>`_ and the source code is available on `GitHub <https://github.com/Techwolf12/charts/tree/main/pretix-helm>`_.
|
||||
|
||||
Docker
|
||||
------
|
||||
|
||||
- `docker compose setup <https://github.com/ZPascal/pretix-docker-compose>`_ by ZPascal
|
||||
@@ -14,3 +14,4 @@ for your needs.
|
||||
manual_smallscale
|
||||
dev_version
|
||||
enterprise
|
||||
community
|
||||
|
||||
@@ -120,6 +120,7 @@ Now we will install pretix itself. The following steps are to be executed as the
|
||||
actually install pretix, we will create a virtual environment to isolate the python packages from your global
|
||||
python installation::
|
||||
|
||||
# sudo -u pretix -s
|
||||
$ python3 -m venv /var/pretix/venv
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install -U pip setuptools wheel
|
||||
@@ -279,6 +280,7 @@ Updates
|
||||
|
||||
To upgrade to a new pretix release, pull the latest code changes and run the following commands::
|
||||
|
||||
# sudo -u pretix -s
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install -U --upgrade-strategy eager pretix gunicorn
|
||||
(venv)$ python -m pretix migrate
|
||||
|
||||
@@ -103,6 +103,12 @@ pretix_celery_tasks_queued_count
|
||||
pretix_celery_tasks_queued_age_seconds
|
||||
The age of the longest-waiting in the worker queue in seconds, labeled with ``queue``.
|
||||
|
||||
pretix_successful_logins
|
||||
Counter. The number of successful backend logins.
|
||||
|
||||
pretix_failed_logins
|
||||
Counter. The number of failed backend logins, labeled with ``reason``.
|
||||
|
||||
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
|
||||
.. _Prometheus: https://prometheus.io/
|
||||
.. _cProfile: https://docs.python.org/3/library/profile.html
|
||||
|
||||
@@ -249,7 +249,10 @@ You can get three response codes:
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"event": "democon",
|
||||
"event": {
|
||||
"name": "Demo Conference",
|
||||
"slug": "democon"
|
||||
},
|
||||
"subevent": 23,
|
||||
"checkinlist": 5
|
||||
}
|
||||
|
||||
@@ -94,7 +94,9 @@ If you want the user to return to your application after the payment is complete
|
||||
"Plugins". Enable the plugin "Redirection from order page". Then, go to the new page "Settings", then "Redirection".
|
||||
Enter the base URL of your web application. This will allow you to redirect to pages under this base URL later on.
|
||||
For example, if you want users to be redirected to ``https://example.org/order/return?tx_id=1234``, you could now
|
||||
either enter ``https://example.org`` or ``https://example.org/order/``.
|
||||
either enter ``https://example.org/order/`` or ``https://example.org/``.
|
||||
Please note that in the latter case the trailing slash is required, ``https://example.org`` is not allowed to prevent.
|
||||
Only base URLs with a secure (``https://``) or local (``http://localhost``) origin are permitted.
|
||||
|
||||
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
|
||||
**regardless of whether it was successful or not**. We will append an ``error=…`` query parameter with an error
|
||||
|
||||
@@ -19,6 +19,7 @@ external_identifier string External ID of
|
||||
the API, but is read-only for customers created through a
|
||||
SSO integration.
|
||||
email string Customer email address
|
||||
phone string Customer phone number
|
||||
name string Name of this customer (or ``null``)
|
||||
name_parts object of strings Decomposition of name (i.e. given name, family name)
|
||||
is_active boolean Whether this account is active
|
||||
@@ -39,6 +40,10 @@ password string Can only be set
|
||||
|
||||
Passwords can now be set through the API during customer creation.
|
||||
|
||||
.. versionchanged:: 2024.3
|
||||
|
||||
The attribute ``phone`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -71,6 +76,7 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "customer@example.org",
|
||||
"phone": "+493012345678",
|
||||
"name": "John Doe",
|
||||
"name_parts": {
|
||||
"_scheme": "full",
|
||||
@@ -118,6 +124,7 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "customer@example.org",
|
||||
"phone": "+493012345678",
|
||||
"name": "John Doe",
|
||||
"name_parts": {
|
||||
"_scheme": "full",
|
||||
@@ -155,6 +162,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"email": "test@example.org",
|
||||
"phone": "+493012345678",
|
||||
"password": "verysecret",
|
||||
"send_email": true
|
||||
}
|
||||
@@ -171,6 +179,7 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "test@example.org",
|
||||
"phone": "+493012345678",
|
||||
...
|
||||
}
|
||||
|
||||
@@ -215,6 +224,7 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "test@example.org",
|
||||
"phone": "+493012345678",
|
||||
…
|
||||
}
|
||||
|
||||
@@ -249,6 +259,7 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": null,
|
||||
"phone": null,
|
||||
…
|
||||
}
|
||||
|
||||
|
||||
@@ -45,8 +45,16 @@ sales_channels list of strings Sales channels
|
||||
available.
|
||||
available_from datetime The first date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
available_from_mode string If ``hide`` (the default), this variation is hidden in the shop
|
||||
if unavailable due to the available_from setting.
|
||||
If ``info``, the variation is visible, but can't be purchased,
|
||||
and a note explaining the unavailability is displayed.
|
||||
available_until datetime The last date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
available_until_mode string If ``hide`` (the default), this variation is hidden in the shop
|
||||
if unavailable due to the available_until setting.
|
||||
If ``info``, the variation is visible, but can't be purchased,
|
||||
and a note explaining the unavailability is displayed.
|
||||
hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
|
||||
redemption process, but not in the normal shop
|
||||
frontend.
|
||||
@@ -105,7 +113,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": {
|
||||
"en": "Test2"
|
||||
@@ -131,7 +141,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": {},
|
||||
"position": 1,
|
||||
@@ -192,7 +204,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0,
|
||||
@@ -232,7 +246,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0,
|
||||
@@ -263,7 +279,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0,
|
||||
@@ -325,7 +343,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1,
|
||||
|
||||
@@ -50,8 +50,16 @@ sales_channels list of strings Sales channel
|
||||
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
|
||||
available_from datetime The first date time at which this item can be bought
|
||||
(or ``null``).
|
||||
available_from_mode string If ``hide`` (the default), this item is hidden in the shop
|
||||
if unavailable due to the ``available_from`` setting.
|
||||
If ``info``, the item is visible, but can't be purchased,
|
||||
and a note explaining the unavailability is displayed.
|
||||
available_until datetime The last date time at which this item can be bought
|
||||
(or ``null``).
|
||||
available_until_mode string If ``hide`` (the default), this item is hidden in the shop
|
||||
if unavailable due to the ``available_until`` setting.
|
||||
If ``info``, the item is visible, but can't be purchased,
|
||||
and a note explaining the unavailability is displayed.
|
||||
hidden_if_available integer **DEPRECATED** The internal ID of a quota object, or ``null``. If
|
||||
set, this item won't be shown publicly as long as this
|
||||
quota is available.
|
||||
@@ -156,8 +164,16 @@ variations list of objects A list with o
|
||||
available.
|
||||
├ available_from datetime The first date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
├ available_from_mode string If ``hide`` (the default), this variation is hidden in the shop
|
||||
if unavailable due to the ``available_from`` setting.
|
||||
If ``info``, the variation is visible, but can't be purchased,
|
||||
and a note explaining the unavailability is displayed.
|
||||
├ available_until datetime The last date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
├ available_until_mode string If ``hide`` (the default), this variation is hidden in the shop
|
||||
if unavailable due to the ``available_until`` setting.
|
||||
If ``info``, the variation is visible, but can't be purchased,
|
||||
and a note explaining the unavailability is displayed.
|
||||
├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
|
||||
redemption process, but not in the normal shop
|
||||
frontend.
|
||||
@@ -279,7 +295,9 @@ Endpoints
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hidden_if_available": null,
|
||||
"hidden_if_item_available": null,
|
||||
"require_voucher": false,
|
||||
@@ -324,7 +342,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -344,7 +364,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -417,7 +439,9 @@ Endpoints
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hidden_if_available": null,
|
||||
"hidden_if_item_available": null,
|
||||
"require_voucher": false,
|
||||
@@ -463,7 +487,9 @@ Endpoints
|
||||
"description": null,
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"meta_data": {},
|
||||
"position": 0
|
||||
@@ -482,7 +508,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -536,7 +564,9 @@ Endpoints
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hidden_if_available": null,
|
||||
"hidden_if_item_available": null,
|
||||
"require_voucher": false,
|
||||
@@ -580,7 +610,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -600,7 +632,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -642,7 +676,9 @@ Endpoints
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hidden_if_available": null,
|
||||
"hidden_if_item_available": null,
|
||||
"require_voucher": false,
|
||||
@@ -687,7 +723,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -707,7 +745,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -780,7 +820,9 @@ Endpoints
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hidden_if_available": null,
|
||||
"hidden_if_item_available": null,
|
||||
"require_voucher": false,
|
||||
@@ -825,7 +867,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -845,7 +889,9 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
|
||||
@@ -179,6 +179,11 @@ country string Attendee countr
|
||||
state string Attendee state (ISO 3166-2 code). Only supported in
|
||||
AU, BR, CA, CN, MY, MX, and US, otherwise ``null``.
|
||||
voucher integer Internal ID of the voucher used for this position (or ``null``)
|
||||
voucher_budget_use money (string) Amount of money discounted by the voucher, corresponding
|
||||
to how much of the ``budget`` of the voucher is consumed.
|
||||
**Important:** Do not rely on this amount to be a useful
|
||||
value if the position's price, product or voucher
|
||||
are changed *after* the order was created. Can be ``null``.
|
||||
tax_rate decimal (string) VAT rate applied for this position
|
||||
tax_value money (string) VAT included in this position
|
||||
tax_rule integer The ID of the used tax rule (or ``null``)
|
||||
@@ -367,6 +372,7 @@ List of all orders
|
||||
"country": "DE",
|
||||
"state": null,
|
||||
"voucher": null,
|
||||
"voucher_budget_use": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_value": "0.00",
|
||||
"tax_rule": null,
|
||||
@@ -589,6 +595,7 @@ Fetching individual orders
|
||||
"country": "DE",
|
||||
"state": null,
|
||||
"voucher": null,
|
||||
"voucher_budget_use": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": null,
|
||||
"tax_value": "0.00",
|
||||
@@ -1541,6 +1548,7 @@ List of all order positions
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"voucher_budget_use": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": null,
|
||||
"tax_value": "0.00",
|
||||
@@ -1654,6 +1662,7 @@ Fetching individual positions
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"voucher_budget_use": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": null,
|
||||
"tax_value": "0.00",
|
||||
|
||||
@@ -22,6 +22,8 @@ id integer Internal ID of
|
||||
name string Team name
|
||||
all_events boolean Whether this team has access to all events
|
||||
limit_events list List of event slugs this team has access to
|
||||
require_2fa boolean Whether members of this team are required to use
|
||||
two-factor authentication
|
||||
can_create_events boolean
|
||||
can_change_teams boolean
|
||||
can_change_organizer_settings boolean
|
||||
@@ -122,6 +124,7 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -159,6 +162,7 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -186,6 +190,7 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -203,6 +208,7 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -246,6 +252,7 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ Core
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification,
|
||||
item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter,
|
||||
register_ticket_secret_generators, gift_card_transaction_display
|
||||
register_ticket_secret_generators, gift_card_transaction_display,
|
||||
register_text_placeholders, register_mail_placeholders
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
|
||||
.. _`importcol`:
|
||||
|
||||
Extending the order import process
|
||||
==================================
|
||||
Extending the import process
|
||||
============================
|
||||
|
||||
It's possible through the backend to import orders into pretix, for example from a legacy ticketing system. If your
|
||||
plugins defines additional data structures around orders, it might be useful to make it possible to import them as well.
|
||||
It's possible through the backend to import objects into pretix, for example orders from a legacy ticketing system. If
|
||||
your plugin defines additional data structures around those objects, it might be useful to make it possible to import
|
||||
them as well.
|
||||
|
||||
Import process
|
||||
--------------
|
||||
@@ -40,7 +41,7 @@ Column registration
|
||||
|
||||
The import API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available import columns. Your plugin
|
||||
should listen for this signal and return the subclass of ``pretix.base.orderimport.ImportColumn``
|
||||
should listen for this signal and return the subclass of ``pretix.base.modelimport.ImportColumn``
|
||||
that we'll provide in this plugin:
|
||||
|
||||
.. sourcecode:: python
|
||||
@@ -56,10 +57,16 @@ that we'll provide in this plugin:
|
||||
EmailColumn(sender),
|
||||
]
|
||||
|
||||
Similar signals exist for other objects:
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: voucher_import_columns
|
||||
|
||||
|
||||
The column class API
|
||||
--------------------
|
||||
|
||||
.. class:: pretix.base.orderimport.ImportColumn
|
||||
.. class:: pretix.base.modelimport.ImportColumn
|
||||
|
||||
The central object of each import extension is the subclass of ``ImportColumn``.
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ convenient to you:
|
||||
|
||||
.. automethod:: _register_fonts
|
||||
|
||||
.. automethod:: _register_event_fonts
|
||||
|
||||
.. automethod:: _on_first_page
|
||||
|
||||
.. automethod:: _on_other_page
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
Writing an e-mail placeholder plugin
|
||||
====================================
|
||||
Writing a template placeholder plugin
|
||||
=====================================
|
||||
|
||||
An email placeholder is a dynamic value that pretix users can use in their email templates.
|
||||
A template placeholder is a dynamic value that pretix users can use in their email templates and in other
|
||||
configurable texts.
|
||||
|
||||
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
|
||||
|
||||
@@ -12,31 +13,31 @@ Placeholder registration
|
||||
------------------------
|
||||
|
||||
The placeholder API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available email placeholders. Your plugin
|
||||
should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``:
|
||||
does use a signal to get a list of all available placeholders. Your plugin
|
||||
should listen for this signal and return an instance of a subclass of ``pretix.base.services.placeholders.BaseTextPlaceholder``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.signals import register_mail_placeholders
|
||||
from pretix.base.signals import register_text_placeholders
|
||||
|
||||
|
||||
@receiver(register_mail_placeholders, dispatch_uid="placeholder_custom")
|
||||
def register_mail_renderers(sender, **kwargs):
|
||||
from .email import MyPlaceholderClass
|
||||
@receiver(register_text_placeholders, dispatch_uid="placeholder_custom")
|
||||
def register_placeholder_renderers(sender, **kwargs):
|
||||
from .placeholders import MyPlaceholderClass
|
||||
return MyPlaceholder()
|
||||
|
||||
|
||||
Context mechanism
|
||||
-----------------
|
||||
|
||||
Emails are sent in different "contexts" within pretix. For example, many emails are sent in the
|
||||
the context of an order, but some are not, such as the notification of a waiting list voucher.
|
||||
Templates are used in different "contexts" within pretix. For example, many emails are rendered from
|
||||
templates in the context of an order, but some are not, such as the notification of a waiting list voucher.
|
||||
|
||||
Not all placeholders make sense in every email, and placeholders usually depend some parameters
|
||||
Not all placeholders make sense everywhere, and placeholders usually depend on some parameters
|
||||
themselves, such as the ``Order`` object. Therefore, placeholders are expected to explicitly declare
|
||||
what values they depend on and they will only be available in an email if all those dependencies are
|
||||
what values they depend on and they will only be available in a context where all those dependencies are
|
||||
met. Currently, placeholders can depend on the following context parameters:
|
||||
|
||||
* ``event``
|
||||
@@ -51,7 +52,7 @@ There are a few more that are only to be used internally but not by plugins.
|
||||
The placeholder class
|
||||
---------------------
|
||||
|
||||
.. class:: pretix.base.email.BaseMailTextPlaceholder
|
||||
.. class:: pretix.base.services.placeholders.BaseTextPlaceholder
|
||||
|
||||
.. autoattribute:: identifier
|
||||
|
||||
@@ -77,7 +78,15 @@ functions:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
placeholder = SimpleFunctionalMailTextPlaceholder(
|
||||
placeholder = SimpleFunctionalTextPlaceholder(
|
||||
'code', ['order'], lambda order: order.code, sample='F8VVL'
|
||||
)
|
||||
|
||||
Signals
|
||||
-------
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: register_text_placeholders
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: register_mail_placeholders
|
||||
|
||||
|
||||
@@ -19,3 +19,4 @@ Contents:
|
||||
permissions
|
||||
logging
|
||||
locking
|
||||
timemachine
|
||||
|
||||
@@ -15,7 +15,7 @@ includes serializers for serializing the following types:
|
||||
* Built-in types: ``int``, ``float``, ``decimal.Decimal``, ``dict``, ``list``, ``bool``
|
||||
* ``datetime.date``, ``datetime.datetime``, ``datetime.time``
|
||||
* ``LazyI18nString``
|
||||
* References to Django ``File`` objects that are already stored in a storage backend
|
||||
* References to Django ``File`` objects that are already stored in a storage backend [#f1]_
|
||||
* References to model instances
|
||||
|
||||
In code, we recommend to always use the ``.get()`` method on the settings object to access a value, but for
|
||||
@@ -55,6 +55,9 @@ You can simply use it like this:
|
||||
"preserve his reservation."),
|
||||
)
|
||||
|
||||
|
||||
.. _settings-defaults-in-plugins:
|
||||
|
||||
Defaults in plugins
|
||||
-------------------
|
||||
|
||||
@@ -70,3 +73,9 @@ Make sure that you include this code in a module that is imported at app loading
|
||||
|
||||
.. _django-hierarkey: https://github.com/raphaelm/django-hierarkey
|
||||
.. _documentation: https://django-hierarkey.readthedocs.io/en/latest/
|
||||
|
||||
.. rubric:: Footnotes
|
||||
|
||||
.. [#f1] If you store ``File`` instances in per-event settings, make sure to always register them with ``add_default``
|
||||
as described above in :ref:`settings-defaults-in-plugins`. Otherwise, the file won't get copied properly if the
|
||||
user copies the settings of an existing event to a new one.
|
||||
|
||||
32
doc/development/implementation/timemachine.rst
Normal file
32
doc/development/implementation/timemachine.rst
Normal file
@@ -0,0 +1,32 @@
|
||||
Time machine mode
|
||||
=================
|
||||
|
||||
In test mode, pretix provides a "time machine" feature which allows event organizers
|
||||
to test their shop as if it were a different date and time. To enable this feature, they can
|
||||
click on the "time machine"-link in the test mode warning box on the event page.
|
||||
|
||||
Internally, this time machine mode is implemented by calling our custom :py:meth:`time_machine_now()`
|
||||
function instead of :py:meth:`django.utils.timezone.now()` in all places where the fake time should be
|
||||
taken into account. If you add code that uses the current date and time for checking whether some
|
||||
product can be bought, you should use :py:meth:`time_machine_now`.
|
||||
|
||||
.. autofunction:: pretix.base.timemachine.time_machine_now
|
||||
|
||||
Background tasks
|
||||
----------------
|
||||
|
||||
The time machine datetime is passed through the request flow via a thread-local variable (ContextVar).
|
||||
Therefore, if you call a background task in the order process, where time_machine_now should be
|
||||
respected, you need to pass it through manually as shown in the example below:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@app.task()
|
||||
def my_task(self, override_now_dt: datetime=None) -> None:
|
||||
with time_machine_now_assigned(override_now_dt):
|
||||
# ...do something that uses time_machine_now()
|
||||
|
||||
my_task.apply_async(kwargs={'override_now_dt': time_machine_now(default=None)})
|
||||
|
||||
|
||||
.. autofunction:: pretix.base.timemachine.time_machine_now_assigned
|
||||
@@ -90,6 +90,10 @@ as its first argument and can be used like this::
|
||||
<a href="{% eventurl request.event "presale:event.checkout" step="payment" %}">Pay</a>
|
||||
<a href="{% abseventurl request.event "presale:event.checkout" step="payment" %}">Pay</a>
|
||||
|
||||
To generate absolute URLs on the main domain, you can use the ``absurl`` template tag::
|
||||
|
||||
{% load eventurl %}
|
||||
<a href="{% absmainurl "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}">Event settings</a>
|
||||
|
||||
Implementation details
|
||||
----------------------
|
||||
|
||||
@@ -211,5 +211,15 @@ with the documentation a lot, you might find it useful to use sphinx-autobuild::
|
||||
Then, go to http://localhost:8081 for a version of the documentation that automatically re-builds
|
||||
whenever you change a source file.
|
||||
|
||||
Working with frontend assets
|
||||
----------------------------
|
||||
|
||||
To update the frontend styles of shops with a custom styling, run the following commands inside
|
||||
your virtual environment.::
|
||||
|
||||
python -m pretix collectstatic --noinput
|
||||
python -m pretix updatestyles
|
||||
|
||||
|
||||
.. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver
|
||||
.. _pretixdroid: https://github.com/pretix/pretixdroid
|
||||
|
||||
@@ -31,7 +31,7 @@ pretix/
|
||||
Additional code implementing our customized :ref:`URL handling <urlconf>`.
|
||||
|
||||
static/
|
||||
Contains all static files (CSS/SASS, JavaScript, images) of pretix' core
|
||||
Contains all static files (CSS/SASS, JavaScript, images) of pretix' core.
|
||||
We use libsass as a preprocessor for CSS. Our own sass code is built in the same
|
||||
step as Bootstrap and FontAwesome, so their mixins etc. are fully available.
|
||||
|
||||
|
||||
@@ -34,13 +34,19 @@ internal_id string Can be used for
|
||||
contact_name string Contact person (or ``null``)
|
||||
contact_name_parts object of strings Decomposition of contact name (i.e. given name, family name)
|
||||
contact_email string Contact person email address (or ``null``)
|
||||
contact_cc_email string Copy email addresses, can be multiple separated by comma (or ``null``)
|
||||
booth string Booth number (or ``null``). Maximum 100 characters.
|
||||
locale string Locale for communication with the exhibitor.
|
||||
access_code string Access code for the exhibitor to access their data or use the lead scanning app (read-only).
|
||||
lead_scanning_access_code string Access code for the exhibitor to use the lead scanning app but not access data (read-only).
|
||||
allow_lead_scanning boolean Enables lead scanning app
|
||||
allow_lead_access boolean Enables access to data gathered by the lead scanning app
|
||||
allow_voucher_access boolean Enables access to data gathered by exhibitor vouchers
|
||||
lead_scanning_scope_by_device string Enables lead scanning to be handled as one lead per attendee
|
||||
per scanning device, instead of only per exhibitor.
|
||||
comment string Internal comment, not shown to exhibitor
|
||||
exhibitor_tags list of strings Internal tags to categorize exhibitors, not shown to exhibitor.
|
||||
The tags need to be created through the web interface currently.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
You can also access the scanned leads through the API which contains the following public fields:
|
||||
@@ -62,6 +68,7 @@ data list of objects Attendee data s
|
||||
except in a few cases where it contains an additional list of objects
|
||||
with ``value`` and ``label`` keys (e.g. splitting of names).
|
||||
device_name string User-defined name for the device used for scanning (or ``null``).
|
||||
device_uuid string UUID of device used for scanning (or ``null``).
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
@@ -105,13 +112,17 @@ Endpoints
|
||||
"title": "Dr"
|
||||
},
|
||||
"contact_email": "johnson@as.example.org",
|
||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
||||
"booth": "A2",
|
||||
"locale": "de",
|
||||
"access_code": "VKHZ2FU8",
|
||||
"access_code": "VKHZ2FU84",
|
||||
"lead_scanning_access_code": "WVK2B8PZ",
|
||||
"lead_scanning_scope_by_device": false,
|
||||
"allow_lead_scanning": true,
|
||||
"allow_lead_access": true,
|
||||
"allow_voucher_access": true,
|
||||
"comment": ""
|
||||
"comment": "",
|
||||
"exhibitor_tags": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -156,13 +167,17 @@ Endpoints
|
||||
"title": "Dr"
|
||||
},
|
||||
"contact_email": "johnson@as.example.org",
|
||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
||||
"booth": "A2",
|
||||
"locale": "de",
|
||||
"access_code": "VKHZ2FU8",
|
||||
"access_code": "VKHZ2FU84",
|
||||
"lead_scanning_access_code": "WVK2B8PZ",
|
||||
"lead_scanning_scope_by_device": false,
|
||||
"allow_lead_scanning": true,
|
||||
"allow_lead_access": true,
|
||||
"allow_voucher_access": true,
|
||||
"comment": ""
|
||||
"comment": "",
|
||||
"exhibitor_tags": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -357,12 +372,16 @@ Endpoints
|
||||
"title": "Dr"
|
||||
},
|
||||
"contact_email": "johnson@as.example.org",
|
||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
||||
"booth": "A2",
|
||||
"locale": "de",
|
||||
"allow_lead_scanning": true,
|
||||
"allow_lead_access": true,
|
||||
"allow_voucher_access": true,
|
||||
"comment": ""
|
||||
"comment": "",
|
||||
"exhibitor_tags": [
|
||||
"Gold Sponsor"
|
||||
]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -386,13 +405,19 @@ Endpoints
|
||||
"title": "Dr"
|
||||
},
|
||||
"contact_email": "johnson@as.example.org",
|
||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
||||
"booth": "A2",
|
||||
"locale": "de",
|
||||
"access_code": "VKHZ2FU8",
|
||||
"access_code": "VKHZ2FU84",
|
||||
"lead_scanning_access_code": "WVK2B8PZ",
|
||||
"lead_scanning_scope_by_device": false,
|
||||
"allow_lead_scanning": true,
|
||||
"allow_lead_access": true,
|
||||
"allow_voucher_access": true,
|
||||
"comment": ""
|
||||
"comment": "",
|
||||
"exhibitor_tags": [
|
||||
"Gold Sponsor"
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create new exhibitor for
|
||||
@@ -444,13 +469,19 @@ Endpoints
|
||||
"title": "Dr"
|
||||
},
|
||||
"contact_email": "johnson@as.example.org",
|
||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
||||
"booth": "A2",
|
||||
"locale": "de",
|
||||
"access_code": "VKHZ2FU8",
|
||||
"access_code": "VKHZ2FU84",
|
||||
"lead_scanning_access_code": "WVK2B8PZ",
|
||||
"lead_scanning_scope_by_device": false,
|
||||
"allow_lead_scanning": true,
|
||||
"allow_lead_access": true,
|
||||
"allow_voucher_access": true,
|
||||
"comment": ""
|
||||
"comment": "",
|
||||
"exhibitor_tags": [
|
||||
"Gold Sponsor"
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
@@ -561,6 +592,7 @@ name string Exhibitor name
|
||||
booth string Booth number (or ``null``)
|
||||
event object Object describing the event
|
||||
├ name multi-lingual string Event name
|
||||
├ end_date datetime End date of the event. After this time, the app could show a warning that the event is over.
|
||||
├ imprint_url string URL to legal notice page. If not ``null``, a button in the app should link to this page.
|
||||
├ privacy_url string URL to privacy notice page. If not ``null``, a button in the app should link to this page.
|
||||
├ help_url string URL to help page. If not ``null``, a button in the app should link to this page.
|
||||
@@ -596,6 +628,7 @@ scan_types list of objects Only used for a
|
||||
"booth": "A2",
|
||||
"event": {
|
||||
"name": {"en": "Sample conference", "de": "Beispielkonferenz"},
|
||||
"end_date": "2017-12-28T10:00:00+00:00",
|
||||
"slug": "bigevents",
|
||||
"imprint_url": null,
|
||||
"privacy_url": null,
|
||||
@@ -634,6 +667,7 @@ On the request, you should set the following properties:
|
||||
* ``tags`` with the list of selected tags
|
||||
* ``rating`` with the rating assigned by the exhibitor
|
||||
* ``device_name`` with a user-specified name of the device used for scanning (max. 190 characters), or ``null``
|
||||
* ``device_uuid`` with a auto-generated UUID of the device used for scanning, or ``null``
|
||||
|
||||
If you submit ``tags`` and ``rating`` to be ``null`` and ``notes`` to be ``""``, the server
|
||||
responds with the previously saved information and will not delete that information. If you
|
||||
@@ -668,7 +702,8 @@ The request for this looks like this:
|
||||
"scan_type": "lead",
|
||||
"tags": ["foo"],
|
||||
"rating": 4,
|
||||
"device_name": "DEV1"
|
||||
"device_name": "DEV1",
|
||||
"device_uuid": "d8c2ec53-d602-4a08-882d-db4cf54344a2"
|
||||
}
|
||||
|
||||
**Example response:**
|
||||
@@ -701,7 +736,9 @@ The request for this looks like this:
|
||||
},
|
||||
"rating": 4,
|
||||
"tags": ["foo"],
|
||||
"notes": "Great customer, wants our newsletter"
|
||||
"notes": "Great customer, wants our newsletter",
|
||||
"device_name": "DEV1",
|
||||
"device_uuid": "d8c2ec53-d602-4a08-882d-db4cf54344a2"
|
||||
}
|
||||
|
||||
:statuscode 200: No error, leads was not scanned for the first time
|
||||
@@ -756,7 +793,9 @@ You can also fetch existing leads (if you are authorized to do so):
|
||||
},
|
||||
"rating": 4,
|
||||
"tags": ["foo"],
|
||||
"notes": "Great customer, wants our newsletter"
|
||||
"notes": "Great customer, wants our newsletter",
|
||||
"device_name": "DEV1",
|
||||
"device_uuid": "d8c2ec53-d602-4a08-882d-db4cf54344a2"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
sphinx==7.0.*
|
||||
sphinx==7.3.*
|
||||
jinja2==3.1.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-e ../
|
||||
sphinx==7.0.*
|
||||
sphinx==7.3.*
|
||||
jinja2==3.1.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
|
||||
113
doc/user/android-version-support.rst
Normal file
113
doc/user/android-version-support.rst
Normal file
@@ -0,0 +1,113 @@
|
||||
Android version support policy
|
||||
==============================
|
||||
|
||||
Building software for Android always presents a struggle between keeping compatibility with older hardware to save cost
|
||||
and utilizing feature of new Android versions to improve functionality, security and stability. To help you plan ahead,
|
||||
we are publishing our intended schedule. This is to be understood as a minimum commitment, we will only drop support for
|
||||
older versions if there is a technical reason to do so, not because the scheduled time has been reached.
|
||||
|
||||
.. warning:: This is a non-binding document. We will try our very best to not to deprecate support for Android versions
|
||||
earlier than listed here, but for technical or economical reasons, it might become necessary to do so under
|
||||
specific circumstances. Specifically, we might be forced to partially drop support for Android versions
|
||||
earlier where we integrate third-party components into our software. Typical examples would be specific
|
||||
payment terminal or printer types where we use a third-party component provided by the hardware vendor.
|
||||
|
||||
If we no longer support an Android version, it means that we will no longer publish new versions of the app supporting
|
||||
that Android version. This means you are not getting new features or bug fixes, and at some point your app might stop
|
||||
working with the pretix server.
|
||||
|
||||
pretixSCAN
|
||||
----------
|
||||
|
||||
=========================== ==========================================================
|
||||
Android Version Support schedule
|
||||
=========================== ==========================================================
|
||||
Android 14 Support planned until at least 12/2029.
|
||||
Android 13 Support planned until at least 12/2028.
|
||||
Android 12 Support planned until at least 12/2027.
|
||||
Android 11 Support planned until at least 12/2026.
|
||||
Android 10 Support planned until at least 12/2025.
|
||||
Android 9 Support planned until at least 12/2025.
|
||||
Android 8 Support planned until at least 12/2025.
|
||||
Android 7 Support planned until at least 06/2025.
|
||||
Android 6 Support planned until at least 06/2025.
|
||||
Android 5 | Support planned until at least 06/2025.
|
||||
| No support for COVID certificate verification.
|
||||
Android 4 Support dropped.
|
||||
=========================== ==========================================================
|
||||
|
||||
pretixPOS
|
||||
---------
|
||||
|
||||
=========================== ==========================================================
|
||||
Android Version Support schedule
|
||||
=========================== ==========================================================
|
||||
Android 14 | Support planned until at least 12/2029.
|
||||
| Limited support for Swissbit microSD TSE (only tested devices).
|
||||
Android 13 | Support planned until at least 12/2028.
|
||||
| Limited support for Swissbit microSD TSE (only tested devices).
|
||||
Android 12 | Support planned until at least 12/2027.
|
||||
| Limited support for Swissbit microSD TSE (only tested devices).
|
||||
Android 11 | Support planned until at least 12/2026.
|
||||
| No support for Swissbit microSD TSE.
|
||||
Android 10 Support planned until at least 12/2025.
|
||||
Android 9 Support planned until at least 12/2025.
|
||||
Android 8 | Support planned until at least 12/2025.
|
||||
| Support for Stripe Terminal on some devices to be dropped 05/2024.
|
||||
Android 7 | Support planned until at least 12/2024.
|
||||
| Support for Stripe Terminal to be dropped 05/2024.
|
||||
| No support for Cryptovision TSE.
|
||||
Android 6 | Support planned until at least 12/2024.
|
||||
| No support for Cryptovision TSE.
|
||||
| No support for Fiskal Cloud.
|
||||
| No support for Stripe Terminal.
|
||||
Android 5 | Support planned until at least 12/2024.
|
||||
| No support for Cryptovision TSE.
|
||||
| No support for Fiskal Cloud.
|
||||
| No support for Stripe Terminal.
|
||||
| No support for SumUp.
|
||||
| No support for COVID certificate verification.
|
||||
Android 4 Support dropped.
|
||||
=========================== ==========================================================
|
||||
|
||||
pretixPRINT
|
||||
-----------
|
||||
|
||||
=========================== ==========================================================
|
||||
Android Version Support schedule
|
||||
=========================== ==========================================================
|
||||
Android 14 Support planned until at least 12/2029.
|
||||
Android 13 Support planned until at least 12/2028.
|
||||
Android 12 Support planned until at least 12/2027.
|
||||
Android 11 Support planned until at least 12/2026.
|
||||
Android 10 Support planned until at least 12/2025.
|
||||
Android 9 Support planned until at least 12/2025.
|
||||
Android 8 Support planned until at least 12/2025.
|
||||
Android 7 Support planned until at least 06/2025.
|
||||
Android 6 Support planned until at least 06/2025.
|
||||
Android 5 | Support planned until at least 06/2025.
|
||||
| No support for Evolis printers on some devices.
|
||||
Android 4.4 | Support planned until at least 06/2024.
|
||||
| No support for USB printers.
|
||||
| No support for Evolis printers.
|
||||
Android 4 Support dropped.
|
||||
=========================== ==========================================================
|
||||
|
||||
pretixLEAD
|
||||
----------
|
||||
|
||||
=========================== ==========================================================
|
||||
Android Version Support schedule
|
||||
=========================== ==========================================================
|
||||
Android 14 Support planned until at least 12/2029.
|
||||
Android 13 Support planned until at least 12/2028.
|
||||
Android 12 Support planned until at least 12/2027.
|
||||
Android 11 Support planned until at least 12/2026.
|
||||
Android 10 Support planned until at least 12/2025.
|
||||
Android 9 Support planned until at least 12/2025.
|
||||
Android 8 Support planned until at least 12/2025.
|
||||
Android 7 Support planned until at least 12/2024.
|
||||
Android 6 Support planned until at least 12/2024.
|
||||
Android 5 Support planned until at least 12/2024.
|
||||
Android 4 Support dropped.
|
||||
=========================== ==========================================================
|
||||
@@ -19,4 +19,3 @@ Then, head to the **Bundled products** tab of the "conference ticket" and add th
|
||||
|
||||
Once a customer tries to buy the € 450 conference ticket, a sub-product will be added and the price will automatically be split into the two components, leading to a correct computation of taxes.
|
||||
|
||||
You can find more use cases in these specialized guides:
|
||||
|
||||
@@ -17,8 +17,8 @@ and then click "Generate widget code".
|
||||
You will obtain two code snippets that look *roughly* like the following. The first should be embedded into the
|
||||
``<head>`` part of your website, if possible. If this inconvenient, you can put it in the ``<body>`` part as well::
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="https://pretix.eu/demo/democon/widget/v1.css">
|
||||
<script type="text/javascript" src="https://pretix.eu/widget/v1.en.js" async></script>
|
||||
<link rel="stylesheet" type="text/css" href="https://pretix.eu/demo/democon/widget/v1.css" crossorigin>
|
||||
<script type="text/javascript" src="https://pretix.eu/widget/v1.en.js" async crossorigin></script>
|
||||
|
||||
The second snippet should be embedded at the position where the widget should show up::
|
||||
|
||||
@@ -339,9 +339,9 @@ Currently, the following attributes are understood by pretix itself:
|
||||
``data-attendee-name``, which will pre-fill the last part of the name, whatever that is.
|
||||
|
||||
* ``data-invoice-address-FIELD`` will pre-fill the corresponding field of the invoice address. Possible values for
|
||||
``FIELD`` are ``company``, ``street``, ``zipcode``, ``city`` and ``country``, as well as fields specified by the
|
||||
naming scheme such as ``name-title`` or ``name-given-name`` (see above). ``country`` expects a two-character
|
||||
country code.
|
||||
``FIELD`` are ``company``, ``street``, ``zipcode``, ``city``, ``country``, ``internal-reference``, ``vat-id``, and
|
||||
``custom-field``, as well as fields specified by the naming scheme such as ``name-title`` or ``name-given-name``
|
||||
(see above). ``country`` expects a two-character country code.
|
||||
|
||||
* If ``data-fix="true"`` is given, the user will not be able to change the other given values later. This currently
|
||||
only works for the order email address as well as the invoice address. Attendee-level fields and questions can
|
||||
@@ -429,4 +429,34 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Offering wallet payments (Apple Pay, Google Pay) within the widget
|
||||
------------------------------------------------------------------
|
||||
|
||||
Some payment providers (such as Stripe) also offer Apple or Google Pay. But in order to use them, the domain of the
|
||||
payment needs to be approved first. As of right now, pretix will take care of the domain verification process for you
|
||||
automatically, when using Stripe. However, pretix can only validate the domain that is being used for your default,
|
||||
"stand-alone" shop (such as https://pretix.eu/demo/democon/ ).
|
||||
|
||||
When embedding the widget on your website, the domain of the embedding page will also need to be validated in order to
|
||||
be able to use it for wallet payments.
|
||||
|
||||
The details might vary from payment provider to payment provider, but generally speaking, it will either involve just
|
||||
telling your payment provider the domain name and (for Apple Pay) placing an
|
||||
``apple-developer-merchantid-domain-association``-file into the ``.well-known``-directory of your domain.
|
||||
|
||||
Further reading:
|
||||
|
||||
* `Stripe Payment Method Domain registration`_
|
||||
|
||||
Working with Cross-Origin-Embedder-Policy
|
||||
-----------------------------------------
|
||||
|
||||
The pretix widget is unfortunately not compatible with ``Cross-Origin-Embedder-Policy: require-corp``. If you include
|
||||
the ``crossorigin`` attributes on the ``<script>`` and ``<link>`` tag as shown above, the widget can show a calendar
|
||||
or product list, but will not be able to open the checkout process in an iframe. If you also set
|
||||
``Cross-Origin-Opener-Policy: same-origin``, the widget can auto-detect that it is running in an isolated enviroment
|
||||
and will instead open the checkout process in a new tab.
|
||||
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
.. _Stripe Payment Method Domain registration: https://stripe.com/docs/payments/payment-methods/pmd-registration
|
||||
|
||||
@@ -16,4 +16,5 @@ wanting to use pretix to sell tickets.
|
||||
events/giftcards
|
||||
faq
|
||||
markdown
|
||||
android-version-support
|
||||
glossary
|
||||
|
||||
@@ -11,6 +11,9 @@ In many places of your shop, like frontpage texts, product descriptions and emai
|
||||
since it is way easier to learn than languages like HTML but allows all basic formatting options required
|
||||
for text in those places.
|
||||
|
||||
.. note:: Some fields that are used in one-line context only allow formatting that refers to individual words
|
||||
(such as bold or italic font or a link) but do not allow block-level formatting like lists or headlines.
|
||||
|
||||
Formatting rules
|
||||
----------------
|
||||
|
||||
|
||||
@@ -30,42 +30,42 @@ dependencies = [
|
||||
"babel",
|
||||
"BeautifulSoup4==4.12.*",
|
||||
"bleach==5.0.*",
|
||||
"celery==5.3.*",
|
||||
"chardet==5.1.*",
|
||||
"celery==5.4.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=3.4.2",
|
||||
"css-inline==0.8.*",
|
||||
"css-inline==0.14.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"dj-static",
|
||||
"Django==4.2.*",
|
||||
"django-bootstrap3==23.1.*",
|
||||
"django-compressor==4.3.*",
|
||||
"django-countries==7.5.*",
|
||||
"django-filter==23.2",
|
||||
"Django[argon2]==4.2.*",
|
||||
"django-bootstrap3==24.2",
|
||||
"django-compressor==4.4",
|
||||
"django-countries==7.6.*",
|
||||
"django-filter==24.2",
|
||||
"django-formset-js-improved==0.5.0.3",
|
||||
"django-formtools==2.4.1",
|
||||
"django-hierarkey==1.1.*",
|
||||
"django-hijack==3.3.*",
|
||||
"django-formtools==2.5.1",
|
||||
"django-hierarkey==1.2.*",
|
||||
"django-hijack==3.4.*",
|
||||
"django-i18nfield==1.9.*,>=1.9.4",
|
||||
"django-libsass==0.9",
|
||||
"django-localflavor==4.0",
|
||||
"django-markup",
|
||||
"django-oauth-toolkit==2.2.*",
|
||||
"django-otp==1.2.*",
|
||||
"django-phonenumber-field==7.1.*",
|
||||
"django-redis==5.2.*",
|
||||
"django-oauth-toolkit==2.3.*",
|
||||
"django-otp==1.5.*",
|
||||
"django-phonenumber-field==7.3.*",
|
||||
"django-redis==5.4.*",
|
||||
"django-scopes==2.0.*",
|
||||
"django-statici18n==2.3.*",
|
||||
"djangorestframework==3.14.*",
|
||||
"dnspython==2.3.*",
|
||||
"django-statici18n==2.5.*",
|
||||
"djangorestframework==3.15.*",
|
||||
"dnspython==2.6.*",
|
||||
"drf_ujson2==1.7.*",
|
||||
"geoip2==4.*",
|
||||
"importlib_metadata==7.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||
"isoweek",
|
||||
"jsonschema",
|
||||
"kombu==5.3.*",
|
||||
"libsass==0.22.*",
|
||||
"libsass==0.23.*",
|
||||
"lxml",
|
||||
"markdown==3.4.3", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
"markdown==3.6", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
|
||||
"mt-940==4.30.*",
|
||||
"oauthlib==3.2.*",
|
||||
@@ -73,27 +73,26 @@ dependencies = [
|
||||
"packaging",
|
||||
"paypalrestsdk==1.13.*",
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.7.*",
|
||||
"PyJWT==2.8.*",
|
||||
"phonenumberslite==8.13.*",
|
||||
"Pillow==9.5.*",
|
||||
"Pillow==10.3.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==4.23.*",
|
||||
"protobuf==5.27.*",
|
||||
"psycopg2-binary",
|
||||
"pycountry",
|
||||
"pycparser==2.21",
|
||||
"pycryptodome==3.18.*",
|
||||
"pypdf==3.9.*",
|
||||
"pycparser==2.22",
|
||||
"pycryptodome==3.20.*",
|
||||
"pypdf==4.2.*",
|
||||
"python-bidi==0.4.*", # Support for Arabic in reportlab
|
||||
"python-dateutil==2.8.*",
|
||||
"python-u2flib-server==4.*",
|
||||
"python-dateutil==2.9.*",
|
||||
"pytz",
|
||||
"pytz-deprecation-shim==0.1.*",
|
||||
"pyuca",
|
||||
"qrcode==7.4.*",
|
||||
"redis==4.6.*",
|
||||
"reportlab==4.0.*",
|
||||
"requests==2.31.*",
|
||||
"sentry-sdk==1.15.*",
|
||||
"redis==5.0.*",
|
||||
"reportlab==4.2.*",
|
||||
"requests==2.32.*",
|
||||
"sentry-sdk==1.45.*",
|
||||
"sepaxml==2.6.*",
|
||||
"slimit",
|
||||
"static3==0.7.*",
|
||||
@@ -101,35 +100,34 @@ dependencies = [
|
||||
"text-unidecode==1.*",
|
||||
"tlds>=2020041600",
|
||||
"tqdm==4.*",
|
||||
"ua-parser==0.18.*",
|
||||
"vat_moss_forked==2020.3.20.0.11.0",
|
||||
"vobject==0.9.*",
|
||||
"webauthn==0.4.*",
|
||||
"webauthn==2.1.*",
|
||||
"zeep==4.2.*"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
memcached = ["pylibmc"]
|
||||
dev = [
|
||||
"aiohttp==3.8.*",
|
||||
"aiohttp==3.9.*",
|
||||
"coverage",
|
||||
"coveralls",
|
||||
"fakeredis==2.18.*",
|
||||
"flake8==6.0.*",
|
||||
"fakeredis==2.23.*",
|
||||
"flake8==7.0.*",
|
||||
"freezegun",
|
||||
"isort==5.12.*",
|
||||
"pep8-naming==0.13.*",
|
||||
"isort==5.13.*",
|
||||
"pep8-naming==0.14.*",
|
||||
"potypo",
|
||||
"pycodestyle==2.10.*",
|
||||
"pyflakes==3.0.*",
|
||||
"pytest-asyncio",
|
||||
"pytest-cache",
|
||||
"pytest-cov",
|
||||
"pytest-django==4.*",
|
||||
"pytest-mock==3.10.*",
|
||||
"pytest-rerunfailures==11.*",
|
||||
"pytest-mock==3.14.*",
|
||||
"pytest-rerunfailures==14.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.3.*",
|
||||
"pytest==7.3.*",
|
||||
"pytest-xdist==3.6.*",
|
||||
"pytest==8.2.*",
|
||||
"responses",
|
||||
]
|
||||
|
||||
|
||||
4
src/.watchmanconfig
Normal file
4
src/.watchmanconfig
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ignore_dirs": ["node_modules", "data", "pretix/static", "pretix/locale", "pretix/static.dist"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ localecompile:
|
||||
./manage.py compilemessages
|
||||
|
||||
localegen:
|
||||
./manage.py makemessages --keep-pot --ignore "pretix/helpers/*" --ignore "pretix/static/npm_dir/*" $(LNGS)
|
||||
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
|
||||
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
|
||||
|
||||
staticfiles: jsi18n
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "2024.1.0"
|
||||
__version__ = "2024.5.0"
|
||||
|
||||
@@ -111,6 +111,7 @@ LANGUAGES_RTL = {
|
||||
LANGUAGES_INCUBATING = {
|
||||
'fi', 'pt-br', 'gl',
|
||||
}
|
||||
LANGUAGES = ALL_LANGUAGES
|
||||
LOCALE_PATHS = [
|
||||
os.path.join(os.path.dirname(__file__), 'locale'),
|
||||
]
|
||||
@@ -234,7 +235,12 @@ COMPRESS_FILTERS = {
|
||||
)
|
||||
}
|
||||
|
||||
CURRENCIES = list(currencies)
|
||||
CURRENCIES = [
|
||||
c for c in currencies
|
||||
if c.alpha_3 not in {
|
||||
'XAG', 'XAU', 'XBA', 'XBB', 'XBC', 'XBD', 'XDR', 'XPD', 'XPT', 'XSU', 'XTS', 'XUA',
|
||||
}
|
||||
]
|
||||
CURRENCY_PLACES = {
|
||||
# default is 2
|
||||
'BIF': 0,
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import exceptions
|
||||
@@ -29,6 +31,8 @@ from pretix.api.auth.devicesecurity import (
|
||||
)
|
||||
from pretix.base.models import Device
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeviceTokenAuthentication(TokenAuthentication):
|
||||
model = Device
|
||||
@@ -46,6 +50,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
|
||||
raise exceptions.AuthenticationFailed('Device has not been initialized.')
|
||||
|
||||
if device.revoked:
|
||||
logging.warning(f'Connection attempt of revoked device {device.pk}.')
|
||||
raise exceptions.AuthenticationFailed('Device access has been revoked.')
|
||||
|
||||
return AnonymousUser(), device
|
||||
|
||||
@@ -39,7 +39,8 @@ from pretix.base.models import Device, Event, User
|
||||
from pretix.base.models.auth import SuperuserPermissionSet
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.helpers.security import (
|
||||
SessionInvalid, SessionReauthRequired, assert_session_valid,
|
||||
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
|
||||
SessionReauthRequired, assert_session_valid,
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +67,10 @@ class EventPermission(BasePermission):
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
except Session2FASetupRequired:
|
||||
return False
|
||||
except SessionPasswordChangeRequired:
|
||||
return False
|
||||
|
||||
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
|
||||
else request.user)
|
||||
@@ -144,6 +149,10 @@ class ProfilePermission(BasePermission):
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
except Session2FASetupRequired:
|
||||
return False
|
||||
except SessionPasswordChangeRequired:
|
||||
return False
|
||||
|
||||
if isinstance(request.auth, OAuthAccessToken):
|
||||
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
|
||||
@@ -166,5 +175,9 @@ class AnyAuthenticatedClientPermission(BasePermission):
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
except Session2FASetupRequired:
|
||||
return False
|
||||
except SessionPasswordChangeRequired:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.10 on 2024-02-12 11:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixapi", "0011_bigint"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="oauthapplication",
|
||||
name="post_logout_redirect_uris",
|
||||
field=models.TextField(default=""),
|
||||
),
|
||||
]
|
||||
@@ -42,6 +42,11 @@ class OAuthApplication(AbstractApplication):
|
||||
verbose_name=_("Redirection URIs"),
|
||||
help_text=_("Allowed URIs list, space separated")
|
||||
)
|
||||
post_logout_redirect_uris = models.TextField(
|
||||
blank=True, validators=[URIValidator],
|
||||
help_text=_("Allowed Post Logout URIs list, space separated"),
|
||||
default="",
|
||||
)
|
||||
client_id = models.CharField(
|
||||
verbose_name=_("Client ID"),
|
||||
max_length=100, unique=True, default=generate_client_id, db_index=True
|
||||
|
||||
@@ -472,7 +472,8 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
|
||||
'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'event', 'is_public',
|
||||
'frontpage_text', 'seating_plan', 'item_price_overrides', 'variation_price_overrides',
|
||||
'meta_data', 'seat_category_mapping', 'last_modified', 'best_availability_state')
|
||||
'meta_data', 'seat_category_mapping', 'last_modified', 'best_availability_state',
|
||||
'comment')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -683,10 +684,12 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'locales',
|
||||
'locale',
|
||||
'region',
|
||||
'last_order_modification_date',
|
||||
'allow_modifications',
|
||||
'allow_modifications_after_checkin',
|
||||
'last_order_modification_date',
|
||||
'show_quota_left',
|
||||
'waiting_list_enabled',
|
||||
'waiting_list_auto_disable',
|
||||
'waiting_list_hours',
|
||||
'waiting_list_auto',
|
||||
'waiting_list_names_asked',
|
||||
@@ -733,6 +736,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'payment_term_accept_late',
|
||||
'payment_explanation',
|
||||
'payment_pending_hidden',
|
||||
'payment_giftcard__enabled',
|
||||
'mail_days_order_expire_warning',
|
||||
'ticket_download',
|
||||
'ticket_download_date',
|
||||
|
||||
@@ -61,7 +61,8 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price', 'free_price_suggestion', 'require_approval',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden',
|
||||
'checkin_attention', 'checkin_text', 'available_from', 'available_until',
|
||||
'checkin_attention', 'checkin_text',
|
||||
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
|
||||
'sales_channels', 'hide_without_voucher', 'meta_data')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -85,7 +86,8 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price', 'free_price_suggestion', 'require_approval',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden',
|
||||
'checkin_attention', 'checkin_text', 'available_from', 'available_until',
|
||||
'checkin_attention', 'checkin_text',
|
||||
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
|
||||
'sales_channels', 'hide_without_voucher', 'meta_data')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -235,7 +237,8 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
model = Item
|
||||
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description',
|
||||
'default_price', 'free_price', 'free_price_suggestion', 'tax_rate', 'tax_rule', 'admission',
|
||||
'personalized', 'position', 'picture', 'available_from', 'available_until',
|
||||
'personalized', 'position', 'picture',
|
||||
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations',
|
||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
||||
|
||||
@@ -486,11 +486,11 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
|
||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled',
|
||||
'valid_from', 'valid_until', 'blocked')
|
||||
'valid_from', 'valid_until', 'blocked', 'voucher_budget_use')
|
||||
read_only_fields = (
|
||||
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
|
||||
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
|
||||
'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked'
|
||||
'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -1315,7 +1315,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if 'valid_from' not in pos_data and 'valid_until' not in pos_data:
|
||||
valid_from, valid_until = pos_data['item'].compute_validity(
|
||||
requested_start=(
|
||||
max(requested_valid_from, now())
|
||||
requested_valid_from
|
||||
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
|
||||
else now()
|
||||
),
|
||||
@@ -1439,6 +1439,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if not pos.item.tax_rule or pos.item.tax_rule.price_includes_tax:
|
||||
price_after_voucher = max(pos.price, pos.voucher.calculate_price(listed_price))
|
||||
else:
|
||||
pos._calculate_tax(invoice_address=ia)
|
||||
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
|
||||
else:
|
||||
price_after_voucher = listed_price
|
||||
@@ -1466,7 +1467,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
answers_data = pos_data.pop('answers', [])
|
||||
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
|
||||
pos = pos_data['__instance']
|
||||
pos._calculate_tax()
|
||||
pos._calculate_tax(invoice_address=ia)
|
||||
|
||||
if simulate:
|
||||
pos = WrappedModel(pos)
|
||||
@@ -1585,7 +1586,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
|
||||
payment_provider = 'free'
|
||||
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
|
||||
if order.total != Decimal('0.00') and order.event.currency == "XXX":
|
||||
raise ValidationError('Paid products not supported without a valid currency.')
|
||||
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID and not validated_data.get('require_approval'):
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
order.payments.create(
|
||||
@@ -1597,6 +1601,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
elif validated_data.get('status') == Order.STATUS_PAID:
|
||||
if not payment_provider:
|
||||
raise ValidationError('You cannot create a paid order without a payment provider.')
|
||||
if validated_data.get('require_approval'):
|
||||
raise ValidationError('You cannot create a paid order that requires approval.')
|
||||
order.payments.create(
|
||||
amount=order.total,
|
||||
provider=payment_provider,
|
||||
|
||||
@@ -79,8 +79,8 @@ class CustomerSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Customer
|
||||
fields = ('identifier', 'external_identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
|
||||
'locale', 'last_modified', 'notes')
|
||||
fields = ('identifier', 'external_identifier', 'email', 'phone', 'name', 'name_parts', 'is_active',
|
||||
'is_verified', 'last_login', 'date_joined', 'locale', 'last_modified', 'notes')
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if instance and instance.provider_id:
|
||||
@@ -239,7 +239,7 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = (
|
||||
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
|
||||
'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
|
||||
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
|
||||
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
|
||||
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
|
||||
|
||||
@@ -89,6 +89,7 @@ class SettingsSerializer(serializers.Serializer):
|
||||
except OSError: # pragma: no cover
|
||||
logger.error('Deleting file %s failed.' % fname.name)
|
||||
instance.delete(attr)
|
||||
self.changed_data.append(attr)
|
||||
else:
|
||||
# file is unchanged
|
||||
continue
|
||||
|
||||
@@ -35,6 +35,7 @@ from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from packaging.version import parse
|
||||
@@ -285,6 +286,8 @@ with scopes_disabled():
|
||||
return queryset.filter(last_checked_in__isnull=not value)
|
||||
|
||||
def check_rules_qs(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
if not self.checkinlist.rules:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
@@ -584,6 +587,32 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
|
||||
}, status=400)
|
||||
else:
|
||||
if media.linked_orderposition.order.event_id not in list_by_event:
|
||||
# Medium exists but connected ticket is for the wrong event
|
||||
if not simulate:
|
||||
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': checkinlists[0].pk,
|
||||
'barcode': raw_barcode,
|
||||
'searched_lists': [cl.pk for cl in checkinlists]
|
||||
}, user=user, auth=auth)
|
||||
Checkin.objects.create(
|
||||
position=None,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_INVALID,
|
||||
error_explanation=gettext('Medium connected to other event'),
|
||||
**common_checkin_args,
|
||||
)
|
||||
return Response({
|
||||
'detail': 'Not found.', # for backwards compatibility
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_INVALID,
|
||||
'reason_explanation': gettext('Medium connected to other event'),
|
||||
'require_attention': False,
|
||||
'checkin_texts': [],
|
||||
'list': MiniCheckinListSerializer(checkinlists[0]).data,
|
||||
}, status=404)
|
||||
op_candidates = [media.linked_orderposition]
|
||||
if list_by_event[media.linked_orderposition.order.event_id].addon_match:
|
||||
op_candidates += list(media.linked_orderposition.addons.all())
|
||||
|
||||
@@ -190,7 +190,10 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
original_data = self.get_serializer(instance=serializer.instance).data
|
||||
|
||||
current_live_value = serializer.instance.live
|
||||
updated_live_value = serializer.validated_data.get('live', None)
|
||||
current_plugins_value = serializer.instance.get_plugins()
|
||||
@@ -198,6 +201,11 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
|
||||
super().perform_update(serializer)
|
||||
|
||||
if serializer.data == original_data:
|
||||
# Performance optimization: If nothing was changed, we do not need to save or log anything.
|
||||
# This costs us a few cycles on save, but avoids thousands of lines in our log.
|
||||
return
|
||||
|
||||
if updated_live_value is not None and updated_live_value != current_live_value:
|
||||
log_action = 'pretix.event.live.activated' if updated_live_value else 'pretix.event.live.deactivated'
|
||||
serializer.instance.log_action(
|
||||
@@ -291,7 +299,7 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance.organizer.log_action(
|
||||
'pretix.event.deleted', user=self.request.user,
|
||||
'pretix.event.deleted', user=self.request.user, auth=self.request.auth,
|
||||
data={
|
||||
'event_id': instance.pk,
|
||||
'name': str(instance.name),
|
||||
@@ -622,11 +630,12 @@ class EventSettingsView(views.APIView):
|
||||
s.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
s.save()
|
||||
self.request.event.log_action(
|
||||
'pretix.event.settings', user=self.request.user, auth=self.request.auth, data={
|
||||
k: v for k, v in s.validated_data.items()
|
||||
}
|
||||
)
|
||||
if s.changed_data:
|
||||
self.request.event.log_action(
|
||||
'pretix.event.settings', user=self.request.user, auth=self.request.auth, data={
|
||||
k: v for k, v in s.validated_data.items()
|
||||
}
|
||||
)
|
||||
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
|
||||
regenerate_css.apply_async(args=(request.event.pk,))
|
||||
s = EventSettingsSerializer(
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import datetime
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from decimal import Decimal
|
||||
@@ -27,7 +28,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import django_filters
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import (
|
||||
Exists, F, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects,
|
||||
)
|
||||
@@ -96,6 +97,9 @@ from pretix.base.signals import (
|
||||
)
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.control.signals import order_search_filter_q
|
||||
from pretix.helpers import OF_SELF
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
with scopes_disabled():
|
||||
class OrderFilter(FilterSet):
|
||||
@@ -572,8 +576,10 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
@transaction.atomic()
|
||||
def create_invoice(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
order = Order.objects.select_for_update(of=OF_SELF).get(pk=order.pk)
|
||||
has_inv = order.invoices.exists() and not (
|
||||
order.status in (Order.STATUS_PAID, Order.STATUS_PENDING)
|
||||
and order.invoices.filter(is_cancellation=True).count() >= order.invoices.filter(is_cancellation=False).count()
|
||||
@@ -900,7 +906,11 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
order_modified.send(sender=serializer.instance.event, order=serializer.instance)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
try:
|
||||
serializer.save()
|
||||
except IntegrityError:
|
||||
logger.exception("Integrity error while saving order")
|
||||
raise ValidationError("Integrity error, possibly duplicate submission of same order.")
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.testmode:
|
||||
@@ -1898,6 +1908,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
return Response(status=204)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
@transaction.atomic()
|
||||
def reissue(self, request, **kwargs):
|
||||
inv = self.get_object()
|
||||
if inv.canceled:
|
||||
@@ -1905,9 +1916,10 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
elif inv.shredded:
|
||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||
else:
|
||||
order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id)
|
||||
c = generate_cancellation(inv)
|
||||
if inv.order.status != Order.STATUS_CANCELED:
|
||||
inv = generate_invoice(inv.order)
|
||||
inv = generate_invoice(order)
|
||||
else:
|
||||
inv = c
|
||||
inv.order.log_action(
|
||||
|
||||
@@ -176,7 +176,7 @@ class ParametrizedItemWebhookEvent(ParametrizedWebhookEvent):
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
class ParametrizedOrderPositionCheckinWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
d = super().build_payload(logentry)
|
||||
@@ -185,6 +185,7 @@ class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
d['orderposition_id'] = logentry.parsed_data.get('position')
|
||||
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
|
||||
d['checkin_list'] = logentry.parsed_data.get('list')
|
||||
d['type'] = logentry.parsed_data.get('type')
|
||||
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
|
||||
return d
|
||||
|
||||
@@ -296,11 +297,11 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
'pretix.event.order.denied',
|
||||
_('Order denied'),
|
||||
),
|
||||
ParametrizedOrderPositionWebhookEvent(
|
||||
ParametrizedOrderPositionCheckinWebhookEvent(
|
||||
'pretix.event.checkin',
|
||||
_('Ticket checked in'),
|
||||
),
|
||||
ParametrizedOrderPositionWebhookEvent(
|
||||
ParametrizedOrderPositionCheckinWebhookEvent(
|
||||
'pretix.event.checkin.reverted',
|
||||
_('Ticket check-in reverted'),
|
||||
),
|
||||
|
||||
@@ -46,7 +46,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import invoice # NOQA
|
||||
from . import notifications # NOQA
|
||||
from . import email # NOQA
|
||||
from .services import auth, checkin, currencies, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .services import auth, checkin, currencies, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .models import _transactions # NOQA
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -19,10 +19,7 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import inspect
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from itertools import groupby
|
||||
from smtplib import SMTPResponseException
|
||||
from typing import TypeVar
|
||||
@@ -33,21 +30,21 @@ from django.core.mail.backends.smtp import EmailBackend
|
||||
from django.db.models import Count
|
||||
from django.dispatch import receiver
|
||||
from django.template.loader import get_template
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import get_language, gettext_lazy as _
|
||||
|
||||
from pretix.base.i18n import (
|
||||
LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber,
|
||||
)
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
|
||||
from pretix.base.signals import (
|
||||
register_html_mail_renderers, register_mail_placeholders,
|
||||
)
|
||||
from pretix.base.signals import register_html_mail_renderers
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
|
||||
from pretix.base.services.placeholders import ( # noqa
|
||||
get_available_placeholders, PlaceholderContext
|
||||
)
|
||||
from pretix.base.services.placeholders import ( # noqa
|
||||
BaseTextPlaceholder as BaseMailTextPlaceholder,
|
||||
SimpleFunctionalTextPlaceholder as SimpleFunctionalMailTextPlaceholder,
|
||||
)
|
||||
from pretix.base.settings import get_name_parts_localized # noqa
|
||||
|
||||
logger = logging.getLogger('pretix.base.email')
|
||||
|
||||
T = TypeVar("T", bound=EmailBackend)
|
||||
@@ -192,7 +189,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
tpl = get_template(self.template_name)
|
||||
body_html = tpl.render(htmlctx)
|
||||
|
||||
inliner = css_inline.CSSInliner(remove_style_tags=True)
|
||||
inliner = css_inline.CSSInliner(keep_style_tags=False)
|
||||
body_html = inliner.inline(body_html)
|
||||
|
||||
return body_html
|
||||
@@ -217,495 +214,5 @@ def base_renderers(sender, **kwargs):
|
||||
return [ClassicMailRenderer, UnembellishedMailRenderer]
|
||||
|
||||
|
||||
class BaseMailTextPlaceholder:
|
||||
"""
|
||||
This is the base class for for all email text placeholders.
|
||||
"""
|
||||
|
||||
@property
|
||||
def required_context(self):
|
||||
"""
|
||||
This property should return a list of all attribute names that need to be
|
||||
contained in the base context so that this placeholder is available. By default,
|
||||
it returns a list containing the string "event".
|
||||
"""
|
||||
return ["event"]
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
This should return the identifier of this placeholder in the email.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render(self, context):
|
||||
"""
|
||||
This method is called to generate the actual text that is being
|
||||
used in the email. You will be passed a context dictionary with the
|
||||
base context attributes specified in ``required_context``. You are
|
||||
expected to return a plain-text string.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render_sample(self, event):
|
||||
"""
|
||||
This method is called to generate a text to be used in email previews.
|
||||
This may only depend on the event.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class SimpleFunctionalMailTextPlaceholder(BaseMailTextPlaceholder):
|
||||
def __init__(self, identifier, args, func, sample):
|
||||
self._identifier = identifier
|
||||
self._args = args
|
||||
self._func = func
|
||||
self._sample = sample
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return self._identifier
|
||||
|
||||
@property
|
||||
def required_context(self):
|
||||
return self._args
|
||||
|
||||
def render(self, context):
|
||||
return self._func(**{k: context[k] for k in self._args})
|
||||
|
||||
def render_sample(self, event):
|
||||
if callable(self._sample):
|
||||
return self._sample(event)
|
||||
else:
|
||||
return self._sample
|
||||
|
||||
|
||||
def get_available_placeholders(event, base_parameters):
|
||||
if 'order' in base_parameters:
|
||||
base_parameters.append('invoice_address')
|
||||
base_parameters.append('position_or_address')
|
||||
params = {}
|
||||
for r, val in register_mail_placeholders.send(sender=event):
|
||||
if not isinstance(val, (list, tuple)):
|
||||
val = [val]
|
||||
for v in val:
|
||||
if all(rp in base_parameters for rp in v.required_context):
|
||||
params[v.identifier] = v
|
||||
return params
|
||||
|
||||
|
||||
def get_email_context(**kwargs):
|
||||
from pretix.base.models import InvoiceAddress
|
||||
|
||||
event = kwargs['event']
|
||||
if 'position' in kwargs:
|
||||
kwargs.setdefault("position_or_address", kwargs['position'])
|
||||
if 'order' in kwargs:
|
||||
try:
|
||||
if not kwargs.get('invoice_address'):
|
||||
kwargs['invoice_address'] = kwargs['order'].invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
kwargs['invoice_address'] = InvoiceAddress(order=kwargs['order'])
|
||||
finally:
|
||||
kwargs.setdefault("position_or_address", kwargs['invoice_address'])
|
||||
ctx = {}
|
||||
for r, val in register_mail_placeholders.send(sender=event):
|
||||
if not isinstance(val, (list, tuple)):
|
||||
val = [val]
|
||||
for v in val:
|
||||
if all(rp in kwargs for rp in v.required_context):
|
||||
try:
|
||||
ctx[v.identifier] = v.render(kwargs)
|
||||
except:
|
||||
ctx[v.identifier] = '(error)'
|
||||
logger.exception(f'Failed to process email placeholder {v.identifier}.')
|
||||
return ctx
|
||||
|
||||
|
||||
def _placeholder_payments(order, payments):
|
||||
d = []
|
||||
for payment in payments:
|
||||
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
|
||||
d.append(str(payment.payment_provider.order_pending_mail_render(order, payment)))
|
||||
else:
|
||||
d.append(str(payment.payment_provider.order_pending_mail_render(order)))
|
||||
d = [line for line in d if line.strip()]
|
||||
if d:
|
||||
return '\n\n'.join(d)
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
def get_best_name(position_or_address, parts=False):
|
||||
"""
|
||||
Return the best name we got for either an invoice address or an order position, falling back to the respective other
|
||||
"""
|
||||
from pretix.base.models import InvoiceAddress, OrderPosition
|
||||
if isinstance(position_or_address, InvoiceAddress):
|
||||
if position_or_address.name:
|
||||
return position_or_address.name_parts if parts else position_or_address.name
|
||||
elif position_or_address.order:
|
||||
position_or_address = position_or_address.order.positions.exclude(attendee_name_cached="").exclude(attendee_name_cached__isnull=True).first()
|
||||
|
||||
if isinstance(position_or_address, OrderPosition):
|
||||
if position_or_address.attendee_name:
|
||||
return position_or_address.attendee_name_parts if parts else position_or_address.attendee_name
|
||||
elif position_or_address.order:
|
||||
try:
|
||||
return position_or_address.order.invoice_address.name_parts if parts else position_or_address.order.invoice_address.name
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
return {} if parts else ""
|
||||
|
||||
|
||||
@receiver(register_mail_placeholders, dispatch_uid="pretixbase_register_mail_placeholders")
|
||||
def base_placeholders(sender, **kwargs):
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
ph = [
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event', ['event'], lambda event: event.name, lambda event: event.name
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
|
||||
lambda event_or_subevent: event_or_subevent.name
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'code', ['order'], lambda order: order.code, 'F8VVL'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'total', ['order'], lambda order: LazyNumber(order.total), lambda event: LazyNumber(Decimal('42.23'))
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'order_email', ['order'], lambda order: order.email, 'john@example.org'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'invoice_number', ['invoice'],
|
||||
lambda invoice: invoice.full_invoice_no,
|
||||
f'{sender.settings.invoice_numbers_prefix or (sender.slug.upper() + "-")}00000'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'refund_amount', ['event_or_subevent', 'refund_amount'],
|
||||
lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency),
|
||||
lambda event_or_subevent: LazyCurrencyNumber(Decimal('42.23'), event_or_subevent.currency)
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'pending_sum', ['event', 'pending_sum'],
|
||||
lambda event, pending_sum: LazyCurrencyNumber(pending_sum, event.currency),
|
||||
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total,
|
||||
event.currency),
|
||||
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'expire_date', ['event', 'order'], lambda event, order: LazyExpiresDate(order.expires.astimezone(event.timezone)),
|
||||
lambda event: LazyDate(now() + timedelta(days=15))
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.open', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
'hash': '98kusd8ofsj8dnkd'
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.modify', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.modify', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_products_change', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.change', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.change', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_cancel', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.cancel', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.cancel', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['event', 'position'], lambda event, position: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.position',
|
||||
kwargs={
|
||||
'order': position.order.code,
|
||||
'secret': position.web_secret,
|
||||
'position': position.positionid
|
||||
}
|
||||
),
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.position', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
'position': '123'
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'order_modification_deadline_date_and_time', ['order', 'event'],
|
||||
lambda order, event:
|
||||
date_format(order.modify_deadline.astimezone(event.timezone), 'SHORT_DATETIME_FORMAT')
|
||||
if order.modify_deadline
|
||||
else '',
|
||||
lambda event: date_format(
|
||||
event.settings.get(
|
||||
'last_order_modification_date', as_type=RelativeDateWrapper
|
||||
).datetime(event).astimezone(event.timezone),
|
||||
'SHORT_DATETIME_FORMAT'
|
||||
) if event.settings.get('last_order_modification_date') else '',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''),
|
||||
lambda event: str(event.location or ''),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event_admission_time', ['event_or_subevent'],
|
||||
lambda event_or_subevent:
|
||||
date_format(event_or_subevent.date_admission.astimezone(event_or_subevent.timezone), 'TIME_FORMAT')
|
||||
if event_or_subevent.date_admission
|
||||
else '',
|
||||
lambda event: date_format(event.date_admission.astimezone(event.timezone), 'TIME_FORMAT') if event.date_admission else '',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'subevent', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: str(waiting_list_entry.subevent or event),
|
||||
lambda event: str(event if not event.has_subevents or not event.subevents.exists() else event.subevents.first())
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'subevent_date_from', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: (waiting_list_entry.subevent or event).get_date_from_display(),
|
||||
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_remove', ['waiting_list_voucher', 'event'],
|
||||
lambda waiting_list_voucher, event: build_absolute_uri(
|
||||
event, 'presale:event.waitinglist.remove'
|
||||
) + '?voucher=' + waiting_list_voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.waitinglist.remove',
|
||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['waiting_list_voucher', 'event'],
|
||||
lambda waiting_list_voucher, event: build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + waiting_list_voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.redeem',
|
||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'invoice_name', ['invoice_address'], lambda invoice_address: invoice_address.name or '',
|
||||
_('John Doe')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
|
||||
_('Sample Corporation')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
order.full_code,
|
||||
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash(),
|
||||
}),
|
||||
)
|
||||
for order in orders
|
||||
), lambda event: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
'{}-{}'.format(event.slug.upper(), order['code']),
|
||||
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'order': order['code'],
|
||||
'secret': order['secret'],
|
||||
'hash': order['hash'],
|
||||
}),
|
||||
)
|
||||
for order in [
|
||||
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'hash': 'abcdefghi'},
|
||||
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd', 'hash': 'jklmnopqr'},
|
||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
|
||||
]
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
|
||||
event.settings.waiting_list_hours,
|
||||
lambda event: event.settings.waiting_list_hours
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'product', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.item.name,
|
||||
_('Sample Admission Ticket')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
|
||||
'68CYU2H6ZTP3WLK5'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
|
||||
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||
'voucher_url_list', ['event', 'voucher_list'],
|
||||
lambda event, voucher_list: ' \n'.join([
|
||||
build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + c
|
||||
for c in voucher_list
|
||||
]),
|
||||
lambda event: ' \n'.join([
|
||||
build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + c
|
||||
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
|
||||
]),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
}), lambda event: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
})
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'name', ['name'], lambda name: name,
|
||||
_('John Doe')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'comment', ['comment'], lambda comment: comment,
|
||||
_('An individual text with a reason can be inserted here.'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'payment_info', ['order', 'payments'], _placeholder_payments,
|
||||
_('The amount has been charged to your card.'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'payment_info', ['payment_info'], lambda payment_info: payment_info,
|
||||
_('Please transfer money to this bank account: 9999-9999-9999-9999'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'attendee_name', ['position'], lambda position: position.attendee_name,
|
||||
_('John Doe'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'positionid', ['position'], lambda position: str(position.positionid),
|
||||
'1'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'name', ['position_or_address'],
|
||||
get_best_name,
|
||||
_('John Doe'),
|
||||
),
|
||||
]
|
||||
|
||||
name_scheme = PERSON_NAME_SCHEMES[sender.settings.name_scheme]
|
||||
if "concatenation_for_salutation" in name_scheme:
|
||||
concatenation_for_salutation = name_scheme["concatenation_for_salutation"]
|
||||
else:
|
||||
concatenation_for_salutation = name_scheme["concatenation"]
|
||||
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
"name_for_salutation", ["waiting_list_entry"],
|
||||
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
|
||||
_("Mr Doe"),
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
"name", ["waiting_list_entry"],
|
||||
lambda waiting_list_entry: waiting_list_entry.name or "",
|
||||
_("Mr Doe"),
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
"name_for_salutation", ["position_or_address"],
|
||||
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
|
||||
_("Mr Doe"),
|
||||
))
|
||||
|
||||
for f, l, w in name_scheme['fields']:
|
||||
if f == 'full_name':
|
||||
continue
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'name_%s' % f, ['waiting_list_entry'], lambda waiting_list_entry, f=f: get_name_parts_localized(waiting_list_entry.name_parts, f),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'attendee_name_%s' % f, ['position'], lambda position, f=f: get_name_parts_localized(position.attendee_name_parts, f),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'name_%s' % f, ['position_or_address'],
|
||||
lambda position_or_address, f=f: get_name_parts_localized(get_best_name(position_or_address, parts=True), f),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
|
||||
for k, v in sender.meta_data.items():
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
|
||||
v
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
|
||||
v
|
||||
))
|
||||
|
||||
return ph
|
||||
return PlaceholderContext(**kwargs).render_all()
|
||||
|
||||
@@ -39,10 +39,12 @@ from zipfile import ZipFile
|
||||
|
||||
from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.models import QuestionAnswer
|
||||
|
||||
from ...control.forms.widgets import Select2
|
||||
from ..exporter import BaseExporter
|
||||
from ..signals import register_data_exporters
|
||||
|
||||
@@ -56,7 +58,7 @@ class AnswerFilesExporter(BaseExporter):
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
return OrderedDict(
|
||||
d = OrderedDict(
|
||||
[
|
||||
('questions',
|
||||
forms.ModelMultipleChoiceField(
|
||||
@@ -69,11 +71,32 @@ class AnswerFilesExporter(BaseExporter):
|
||||
)),
|
||||
]
|
||||
)
|
||||
if self.event.has_subevents:
|
||||
d['subevent'] = forms.ModelChoiceField(
|
||||
label=pgettext_lazy('subevent', 'Date'),
|
||||
queryset=self.event.subevents.all(),
|
||||
required=False,
|
||||
empty_label=pgettext_lazy('subevent', 'All dates')
|
||||
)
|
||||
d['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'All dates')
|
||||
}
|
||||
)
|
||||
d['subevent'].widget.choices = d['subevent'].choices
|
||||
return d
|
||||
|
||||
def render(self, form_data: dict):
|
||||
qs = QuestionAnswer.objects.filter(
|
||||
orderposition__order__event=self.event,
|
||||
).select_related('orderposition', 'orderposition__order', 'question')
|
||||
if form_data.get('subevent'):
|
||||
qs = qs.filter(orderposition__subevent=form_data.get('subevent'))
|
||||
if form_data.get('questions'):
|
||||
qs = qs.filter(question__in=form_data['questions'])
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
|
||||
@@ -116,15 +116,29 @@ class DekodiNREIExporter(BaseExporter):
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
elif p.provider and p.provider.startswith('stripe'):
|
||||
src = p.info_data.get("source", p.info_data)
|
||||
pi = p.info_data or {}
|
||||
try:
|
||||
if "latest_charge" in pi and isinstance(pi.get("latest_charge"), dict):
|
||||
details = pi["latest_charge"]["payment_method_details"]
|
||||
card = details.get("card", {})
|
||||
elif pi.get("charges") and pi["charges"]["data"]:
|
||||
details = pi["charges"]["data"][0].get("payment_method_details", {})
|
||||
card = details.get("card", {})
|
||||
else:
|
||||
details = pi["source"]
|
||||
card = pi["source"]["card"]
|
||||
except:
|
||||
details = {}
|
||||
card = {}
|
||||
|
||||
payments.append({
|
||||
'PTID': '81',
|
||||
'PTN': 'Stripe',
|
||||
'PTNo1': p.info_data.get("id") or '',
|
||||
'PTNo5': src.get("card", {}).get("last4") or '',
|
||||
'PTNo1': pi.get("id") or '',
|
||||
'PTNo5': card.get("last4", ""),
|
||||
'PTNo7': round(float(p.amount), 2) or '',
|
||||
'PTNo8': str(self.event.currency) or '',
|
||||
'PTNo10': src.get('owner', {}).get('verified_name') or src.get('owner', {}).get('name') or '',
|
||||
'PTNo10': details.get('owner', {}).get('verified_name') or details.get('owner', {}).get('name') or '',
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
else:
|
||||
|
||||
@@ -86,6 +86,7 @@ class InvoiceExporterMixin:
|
||||
('', _('All payment providers')),
|
||||
] + [
|
||||
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
|
||||
if not v.is_meta
|
||||
],
|
||||
required=False,
|
||||
help_text=_('Only include invoices for orders that have at least one payment attempt '
|
||||
|
||||
@@ -209,7 +209,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
return qs.annotate(**annotations).filter(**filters)
|
||||
return qs
|
||||
|
||||
def iterate_orders(self, form_data: dict):
|
||||
def orders_qs(self, form_data):
|
||||
p_date = OrderPayment.objects.filter(
|
||||
order=OuterRef('pk'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
@@ -250,11 +250,15 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(status=Order.STATUS_PAID)
|
||||
return qs
|
||||
|
||||
def iterate_orders(self, form_data: dict):
|
||||
qs = self.orders_qs(form_data)
|
||||
tax_rates = self._get_all_tax_rates(qs)
|
||||
|
||||
headers = [
|
||||
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Phone number'),
|
||||
_('Order date'), _('Order time'), _('Company'), _('Name'),
|
||||
_('Event slug'), _('Event name'), _('Order code'), _('Order total'), _('Status'), _('Email'),
|
||||
_('Phone number'), _('Order date'), _('Order time'), _('Company'), _('Name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
@@ -331,6 +335,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
row = [
|
||||
self.event_object_cache[order.event_id].slug,
|
||||
str(self.event_object_cache[order.event_id].name),
|
||||
order.code,
|
||||
order.total,
|
||||
order.get_extended_status_display(),
|
||||
@@ -406,7 +411,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
row += self.event_object_cache[order.event_id].meta_data.values()
|
||||
yield row
|
||||
|
||||
def iterate_fees(self, form_data: dict):
|
||||
def fees_qs(self, form_data):
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
@@ -425,9 +430,14 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
||||
|
||||
qs = self._date_filter(qs, form_data, rel='order__')
|
||||
return qs
|
||||
|
||||
def iterate_fees(self, form_data: dict):
|
||||
qs = self.fees_qs(form_data)
|
||||
|
||||
headers = [
|
||||
_('Event slug'),
|
||||
_('Event name'),
|
||||
_('Order code'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
@@ -464,6 +474,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
tz = ZoneInfo(order.event.settings.timezone)
|
||||
row = [
|
||||
self.event_object_cache[order.event_id].slug,
|
||||
str(self.event_object_cache[order.event_id].name),
|
||||
order.code,
|
||||
_("canceled") if op.canceled else order.get_extended_status_display(),
|
||||
order.email,
|
||||
@@ -506,7 +517,19 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
row += self.event_object_cache[order.event_id].meta_data.values()
|
||||
yield row
|
||||
|
||||
def positions_qs(self, form_data: dict):
|
||||
qs = OrderPosition.all.filter(
|
||||
order__event__in=self.events,
|
||||
)
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
||||
|
||||
qs = self._date_filter(qs, form_data, rel='order__')
|
||||
return qs
|
||||
|
||||
def iterate_positions(self, form_data: dict):
|
||||
base_qs = self.positions_qs(form_data)
|
||||
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
@@ -516,9 +539,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
base_qs = OrderPosition.all.filter(
|
||||
order__event__in=self.events,
|
||||
)
|
||||
qs = base_qs.annotate(
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
).select_related(
|
||||
@@ -528,15 +548,12 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
'subevent', 'subevent__meta_values',
|
||||
'answers', 'answers__question', 'answers__options'
|
||||
)
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
||||
|
||||
qs = self._date_filter(qs, form_data, rel='order__')
|
||||
|
||||
has_subevents = self.events.filter(has_subevents=True).exists()
|
||||
|
||||
headers = [
|
||||
_('Event slug'),
|
||||
_('Event name'),
|
||||
_('Order code'),
|
||||
_('Position ID'),
|
||||
_('Status'),
|
||||
@@ -638,6 +655,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
tz = ZoneInfo(self.event_object_cache[order.event_id].settings.timezone)
|
||||
row = [
|
||||
self.event_object_cache[order.event_id].slug,
|
||||
str(self.event_object_cache[order.event_id].name),
|
||||
order.code,
|
||||
op.positionid,
|
||||
_("canceled") if op.canceled else order.get_extended_status_display(),
|
||||
|
||||
@@ -39,6 +39,7 @@ from django import forms
|
||||
from django.core.validators import URLValidator
|
||||
from django.forms.models import ModelFormMetaclass
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from formtools.wizard.views import SessionWizardView
|
||||
from hierarkey.forms import HierarkeyForm
|
||||
@@ -85,6 +86,43 @@ class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class MarkdownTextarea(forms.Textarea):
|
||||
|
||||
def _render(self, template_name, context, renderer=None):
|
||||
return mark_safe(
|
||||
'<div class="i18n-form-group">%s<div class="i18n-field-markdown-note">%s</div></div>' % (
|
||||
super()._render(template_name, context, renderer=None),
|
||||
_("You can use {markup_name} in this field.").format(
|
||||
markup_name='<a href="https://docs.pretix.eu/en/latest/user/markdown.html" target="_blank">Markdown</a>'
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class I18nMarkdownTextarea(i18nfield.forms.I18nTextarea):
|
||||
def format_output(self, rendered_widgets) -> str:
|
||||
rendered_widgets = rendered_widgets + [
|
||||
'<div class="i18n-field-markdown-note">%s</div>' % (
|
||||
_("You can use {markup_name} in this field.").format(
|
||||
markup_name='<a href="https://docs.pretix.eu/en/latest/user/markdown.html" target="_blank">Markdown</a>'
|
||||
)
|
||||
)
|
||||
]
|
||||
return super().format_output(rendered_widgets)
|
||||
|
||||
|
||||
class I18nMarkdownTextInput(i18nfield.forms.I18nTextInput):
|
||||
def format_output(self, rendered_widgets) -> str:
|
||||
rendered_widgets = rendered_widgets + [
|
||||
'<div class="i18n-field-markdown-note">%s</div>' % (
|
||||
_("You can use {markup_name} in this field.").format(
|
||||
markup_name='<a href="https://docs.pretix.eu/en/latest/user/markdown.html" target="_blank">Markdown</a>'
|
||||
)
|
||||
)
|
||||
]
|
||||
return super().format_output(rendered_widgets)
|
||||
|
||||
|
||||
SECRET_REDACTED = '*****'
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
@@ -44,10 +45,13 @@ from django.contrib.auth.password_validation import (
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.metrics import pretix_failed_logins
|
||||
from pretix.base.models import User
|
||||
from pretix.helpers.dicts import move_to_end
|
||||
from pretix.helpers.http import get_client_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
"""
|
||||
@@ -55,6 +59,7 @@ class LoginForm(forms.Form):
|
||||
username/password logins.
|
||||
"""
|
||||
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
||||
origin = forms.CharField(widget=forms.HiddenInput, required=False)
|
||||
|
||||
error_messages = {
|
||||
'invalid_login': _("This combination of credentials is not known to our system."),
|
||||
@@ -104,12 +109,16 @@ class LoginForm(forms.Form):
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.get(self.ratelimit_key)
|
||||
if cnt and int(cnt) > 10:
|
||||
pretix_failed_logins.inc(1, reason="ratelimit")
|
||||
logger.info("Backend login rejected due to rate limit.")
|
||||
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
|
||||
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
|
||||
if self.user_cache is None:
|
||||
if self.ratelimit_key:
|
||||
rc.incr(self.ratelimit_key)
|
||||
rc.expire(self.ratelimit_key, 300)
|
||||
logger.info("Backend login invalid.")
|
||||
pretix_failed_logins.inc(1, reason="invalid")
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['invalid_login'],
|
||||
code='invalid_login'
|
||||
@@ -131,6 +140,8 @@ class LoginForm(forms.Form):
|
||||
If the given user may log in, this method should return None.
|
||||
"""
|
||||
if not user.is_active:
|
||||
logger.info("Backend login rejected due to user inactive.")
|
||||
pretix_failed_logins.inc(1, reason="inactive")
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['inactive'],
|
||||
code='inactive',
|
||||
|
||||
@@ -57,7 +57,7 @@ from django.forms.widgets import FILE_INPUT_CONTRADICTION
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.timezone import get_current_timezone
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries import countries
|
||||
from django_countries.fields import Country, CountryField
|
||||
@@ -86,6 +86,7 @@ from pretix.base.settings import (
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
|
||||
)
|
||||
@@ -606,30 +607,41 @@ class BaseQuestionsForm(forms.Form):
|
||||
|
||||
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
|
||||
if item.validity_dynamic_start_choice_day_limit:
|
||||
max_date = now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
|
||||
max_date = time_machine_now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
|
||||
else:
|
||||
max_date = None
|
||||
min_date = time_machine_now()
|
||||
initial = None
|
||||
if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership:
|
||||
if pos.used_membership.date_start >= time_machine_now():
|
||||
initial = min_date = pos.used_membership.date_start
|
||||
max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end
|
||||
if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
|
||||
attrs = {}
|
||||
if max_date:
|
||||
attrs['data-max'] = max_date.date().isoformat()
|
||||
if min_date:
|
||||
attrs['data-min'] = min_date.date().isoformat()
|
||||
self.fields['requested_valid_from'] = forms.DateField(
|
||||
label=_('Start date'),
|
||||
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=False,
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
widget=DatePickerWidget(attrs),
|
||||
validators=[MaxDateValidator(max_date.date())] if max_date else []
|
||||
validators=([MaxDateValidator(max_date.date())] if max_date else []) + [MinDateValidator(min_date.date())]
|
||||
)
|
||||
else:
|
||||
self.fields['requested_valid_from'] = forms.SplitDateTimeField(
|
||||
label=_('Start date'),
|
||||
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=False,
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
min_date=min_date,
|
||||
max_date=max_date
|
||||
),
|
||||
validators=[MaxDateTimeValidator(max_date)] if max_date else []
|
||||
validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)]
|
||||
)
|
||||
|
||||
add_fields = {}
|
||||
@@ -1023,7 +1035,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.all_optional = kwargs.pop('all_optional', False)
|
||||
|
||||
kwargs.setdefault('initial', {})
|
||||
if not kwargs.get('instance') or not kwargs['instance'].country:
|
||||
if (not kwargs.get('instance') or not kwargs['instance'].country) and not kwargs["initial"].get("country"):
|
||||
kwargs['initial']['country'] = guess_country_from_request(self.request, self.event)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -1159,7 +1171,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.instance.vat_id_validated = False
|
||||
messages.warning(self.request, e.message)
|
||||
else:
|
||||
raise ValidationError(e.message)
|
||||
raise ValidationError({"vat_id": e.message})
|
||||
except VATIDTemporaryError as e:
|
||||
self.instance.vat_id_validated = False
|
||||
if self.request and self.vat_warning:
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from bootstrap3.renderers import (
|
||||
FieldRenderer as BaseFieldRenderer,
|
||||
InlineFieldRenderer as BaseInlineFieldRenderer,
|
||||
)
|
||||
from django.forms import (
|
||||
CheckboxInput, CheckboxSelectMultiple, ClearableFileInput, RadioSelect,
|
||||
SelectDateWidget,
|
||||
)
|
||||
|
||||
|
||||
class FieldRenderer(BaseFieldRenderer):
|
||||
# Local application of https://github.com/zostera/django-bootstrap3/pull/859
|
||||
|
||||
def post_widget_render(self, html):
|
||||
if isinstance(self.widget, CheckboxSelectMultiple):
|
||||
html = self.list_to_class(html, "checkbox")
|
||||
elif isinstance(self.widget, RadioSelect):
|
||||
html = self.list_to_class(html, "radio")
|
||||
elif isinstance(self.widget, SelectDateWidget):
|
||||
html = self.fix_date_select_input(html)
|
||||
elif isinstance(self.widget, ClearableFileInput):
|
||||
html = self.fix_clearable_file_input(html)
|
||||
elif isinstance(self.widget, CheckboxInput):
|
||||
html = self.put_inside_label(html)
|
||||
return html
|
||||
|
||||
|
||||
class InlineFieldRenderer(BaseInlineFieldRenderer):
|
||||
# Local application of https://github.com/zostera/django-bootstrap3/pull/859
|
||||
|
||||
def post_widget_render(self, html):
|
||||
if isinstance(self.widget, CheckboxSelectMultiple):
|
||||
html = self.list_to_class(html, "checkbox")
|
||||
elif isinstance(self.widget, RadioSelect):
|
||||
html = self.list_to_class(html, "radio")
|
||||
elif isinstance(self.widget, SelectDateWidget):
|
||||
html = self.fix_date_select_input(html)
|
||||
elif isinstance(self.widget, ClearableFileInput):
|
||||
html = self.fix_clearable_file_input(html)
|
||||
elif isinstance(self.widget, CheckboxInput):
|
||||
html = self.put_inside_label(html)
|
||||
return html
|
||||
@@ -33,7 +33,7 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import os
|
||||
from datetime import date
|
||||
from datetime import datetime
|
||||
|
||||
from django import forms
|
||||
from django.utils.formats import get_format
|
||||
@@ -188,11 +188,11 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
time_attrs['autocomplete'] = 'off'
|
||||
if min_date:
|
||||
date_attrs['data-min'] = (
|
||||
min_date if isinstance(min_date, date) else min_date.astimezone(get_current_timezone()).date()
|
||||
min_date if not isinstance(min_date, datetime) else min_date.astimezone(get_current_timezone()).date()
|
||||
).isoformat()
|
||||
if max_date:
|
||||
date_attrs['data-max'] = (
|
||||
max_date if isinstance(max_date, date) else max_date.astimezone(get_current_timezone()).date()
|
||||
max_date if not isinstance(max_date, datetime) else max_date.astimezone(get_current_timezone()).date()
|
||||
).isoformat()
|
||||
|
||||
def date_placeholder():
|
||||
|
||||
@@ -182,7 +182,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
|
||||
italic='OpenSansIt', boldItalic='OpenSansBI')
|
||||
|
||||
for family, styles in get_fonts().items():
|
||||
for family, styles in get_fonts(event=self.event, pdf_support_required=True).items():
|
||||
if family == self.event.settings.invoice_renderer_font:
|
||||
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
||||
self.font_regular = family
|
||||
@@ -625,7 +625,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
)]
|
||||
else:
|
||||
tdata = [(
|
||||
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['BoldRight']),
|
||||
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
|
||||
Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
|
||||
Paragraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
|
||||
)]
|
||||
@@ -855,7 +855,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
class Modern1Renderer(ClassicInvoiceRenderer):
|
||||
identifier = 'modern1'
|
||||
verbose_name = gettext_lazy('Modern Invoice Renderer (pretix 2.7)')
|
||||
verbose_name = gettext_lazy('Default invoice renderer (European-style letter)')
|
||||
bottom_margin = 16.9 * mm
|
||||
top_margin = 16.9 * mm
|
||||
right_margin = 20 * mm
|
||||
@@ -989,6 +989,37 @@ class Modern1Renderer(ClassicInvoiceRenderer):
|
||||
canvas.drawText(textobject)
|
||||
|
||||
|
||||
class Modern1SimplifiedRenderer(Modern1Renderer):
|
||||
identifier = 'modern1simplified'
|
||||
verbose_name = gettext_lazy('Simplified invoice renderer')
|
||||
|
||||
logo_left = Modern1Renderer.left_margin
|
||||
logo_width = pagesizes.A4[0] - Modern1Renderer.right_margin - logo_left
|
||||
logo_height = 25 * mm
|
||||
logo_top = 13 * mm
|
||||
logo_anchor = 'nw'
|
||||
|
||||
def _draw_invoice_from(self, canvas):
|
||||
super(Modern1Renderer, self)._draw_invoice_from(canvas)
|
||||
|
||||
def _draw_event(self, canvas):
|
||||
pass
|
||||
|
||||
def _get_intro(self):
|
||||
i = []
|
||||
|
||||
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
|
||||
i.append(Paragraph(
|
||||
pgettext('invoice', 'Event date: {date_range}').format(
|
||||
date_range=self.invoice.event.get_date_range_display(),
|
||||
),
|
||||
self.stylesheet['Normal'],
|
||||
))
|
||||
i.append(Spacer(2 * mm, 2 * mm))
|
||||
|
||||
return i + super()._get_intro()
|
||||
|
||||
|
||||
@receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic")
|
||||
def recv_classic(sender, **kwargs):
|
||||
return [ClassicInvoiceRenderer, Modern1Renderer]
|
||||
return [ClassicInvoiceRenderer, Modern1Renderer, Modern1SimplifiedRenderer]
|
||||
|
||||
@@ -268,7 +268,10 @@ def metric_values():
|
||||
dkey = key.decode("utf-8")
|
||||
splitted = dkey.split("{", 2)
|
||||
value = float(value.decode("utf-8"))
|
||||
metrics[splitted[0]]["{" + splitted[1]] = value
|
||||
if len(splitted) == 1:
|
||||
metrics[splitted[0]][""] = value
|
||||
else:
|
||||
metrics[splitted[0]]["{" + splitted[1]] = value
|
||||
|
||||
# Aliases
|
||||
aliases = {
|
||||
@@ -314,3 +317,5 @@ pretix_task_runs_total = Counter("pretix_task_runs_total", "Total calls to a cel
|
||||
["task_name", "status"])
|
||||
pretix_task_duration_seconds = Histogram("pretix_task_duration_seconds", "Call time of a celery task",
|
||||
["task_name"])
|
||||
pretix_successful_logins = Counter("pretix_logins_successful", "Successful logins", [])
|
||||
pretix_failed_logins = Counter("pretix_logins_failed", "Failed logins", ["reason"])
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from collections import OrderedDict
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import urlparse, urlsplit
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from django.conf import settings
|
||||
@@ -40,6 +40,7 @@ from pretix.base.settings import global_settings_object
|
||||
from pretix.multidomain.urlreverse import (
|
||||
get_event_domain, get_organizer_domain,
|
||||
)
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
_supported = None
|
||||
|
||||
@@ -240,6 +241,14 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -259,6 +268,14 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
if gs.settings.leaflet_tiles:
|
||||
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
|
||||
|
||||
font_src = set()
|
||||
if hasattr(request, 'event'):
|
||||
for font in get_fonts(request.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))
|
||||
|
||||
h = {
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ['{static}'],
|
||||
@@ -267,7 +284,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}"],
|
||||
'img-src': ["{static}", "{media}", "data:"] + img_src,
|
||||
'font-src': ["{static}"],
|
||||
'font-src': ["{static}"] + list(font_src),
|
||||
'media-src': ["{static}", "data:"],
|
||||
# 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
|
||||
|
||||
22
src/pretix/base/migrations/0255_item_unavail_modes.py
Normal file
22
src/pretix/base/migrations/0255_item_unavail_modes.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.2.4 on 2023-11-22 20:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0254_alter_logentry_organizer_link_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="item",
|
||||
name="available_from_mode",
|
||||
field=models.CharField(default="hide", max_length=16),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="item",
|
||||
name="available_until_mode",
|
||||
field=models.CharField(default="hide", max_length=16),
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.2.4 on 2024-01-11 15:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0255_item_unavail_modes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="itemvariation",
|
||||
name="available_from_mode",
|
||||
field=models.CharField(default="hide", max_length=16),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="itemvariation",
|
||||
name="available_until_mode",
|
||||
field=models.CharField(default="hide", max_length=16),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.2.9 on 2024-01-30 11:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0256_itemvariation_unavail_modes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="item",
|
||||
name="default_price",
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=13),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
48
src/pretix/base/migrations/0258_uniq_indx.py
Normal file
48
src/pretix/base/migrations/0258_uniq_indx.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 4.2.10 on 2024-03-15 09:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0257_item_default_price_not_null"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="order",
|
||||
name="organizer",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="orders",
|
||||
to="pretixbase.organizer",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="orderposition",
|
||||
name="organizer",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="order_positions",
|
||||
to="pretixbase.organizer",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="order",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("organizer", "code"), name="order_organizer_code_uniq"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="orderposition",
|
||||
constraint=models.UniqueConstraint(
|
||||
models.F("organizer"),
|
||||
models.F("secret"),
|
||||
name="orderposition_organizer_secret_uniq",
|
||||
),
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0259_team_require_2fa.py
Normal file
18
src/pretix/base/migrations/0259_team_require_2fa.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.10 on 2024-04-02 11:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0258_uniq_indx"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="team",
|
||||
name="require_2fa",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.2.10 on 2024-04-02 15:16
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0259_team_require_2fa"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterIndexTogether(
|
||||
name="reusablemedium",
|
||||
index_together=set(),
|
||||
),
|
||||
]
|
||||
48
src/pretix/base/migrations/0261_userknownloginsource.py
Normal file
48
src/pretix/base/migrations/0261_userknownloginsource.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 4.2.10 on 2024-04-02 15:37
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.countries
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0260_alter_reusablemedium_index_together"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserKnownLoginSource",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("agent_type", models.CharField(max_length=255, null=True)),
|
||||
("device_type", models.CharField(max_length=255, null=True)),
|
||||
("os_type", models.CharField(max_length=255, null=True)),
|
||||
(
|
||||
"country",
|
||||
pretix.helpers.countries.FastCountryField(
|
||||
countries=pretix.helpers.countries.CachedCountries,
|
||||
max_length=2,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("last_seen", models.DateTimeField()),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="known_login_sources",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0262_subevent_comment.py
Normal file
18
src/pretix/base/migrations/0262_subevent_comment.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.10 on 2024-04-19 14:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0261_userknownloginsource"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="subevent",
|
||||
name="comment",
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
]
|
||||
26
src/pretix/base/migrations/0263_auto_20240409_0732.py
Normal file
26
src/pretix/base/migrations/0263_auto_20240409_0732.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.10 on 2024-04-09 07:32
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def change_currencies(apps, schema_editor):
|
||||
Event = apps.get_model("pretixbase", "Event")
|
||||
Event.objects.filter(
|
||||
currency__in={
|
||||
'XAG', 'XAU', 'XBA', 'XBB', 'XBC', 'XBD', 'XDR', 'XPD', 'XPT', 'XSU', 'XTS', 'XUA',
|
||||
}
|
||||
).update(currency='XXX')
|
||||
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0262_subevent_comment"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
change_currencies, migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
24
src/pretix/base/migrations/0264_order_internal_secret.py
Normal file
24
src/pretix/base/migrations/0264_order_internal_secret.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.11 on 2024-05-16 11:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.orders
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0263_auto_20240409_0732"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="order",
|
||||
name="internal_secret",
|
||||
field=models.CharField(
|
||||
default=None,
|
||||
max_length=32,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
287
src/pretix/base/modelimport.py
Normal file
287
src/pretix/base/modelimport.py
Normal file
@@ -0,0 +1,287 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import csv
|
||||
import datetime
|
||||
import io
|
||||
import re
|
||||
from decimal import Decimal, DecimalException
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_integer
|
||||
from django.utils import formats
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext
|
||||
|
||||
from pretix.base.i18n import LazyLocaleException
|
||||
from pretix.base.models import SubEvent
|
||||
|
||||
|
||||
class DataImportError(LazyLocaleException):
|
||||
def __init__(self, *args):
|
||||
msg = args[0]
|
||||
msgargs = args[1] if len(args) > 1 else None
|
||||
self.args = args
|
||||
if msgargs:
|
||||
msg = _(msg) % msgargs
|
||||
else:
|
||||
msg = _(msg)
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
def parse_csv(file, length=None, mode="strict", charset=None):
|
||||
file.seek(0)
|
||||
data = file.read(length)
|
||||
if not charset:
|
||||
try:
|
||||
import chardet
|
||||
charset = chardet.detect(data)['encoding']
|
||||
except ImportError:
|
||||
charset = file.charset
|
||||
data = data.decode(charset or "utf-8", mode)
|
||||
# If the file was modified on a Mac, it only contains \r as line breaks
|
||||
if '\r' in data and '\n' not in data:
|
||||
data = data.replace('\r', '\n')
|
||||
|
||||
try:
|
||||
dialect = csv.Sniffer().sniff(data.split("\n")[0], delimiters=";,.#:")
|
||||
except csv.Error:
|
||||
return None
|
||||
|
||||
if dialect is None:
|
||||
return None
|
||||
|
||||
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
|
||||
return reader
|
||||
|
||||
|
||||
class ImportColumn:
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
Unique, internal name of the column.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
"""
|
||||
Human-readable description of the column
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
"""
|
||||
Initial value for the form component
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def default_value(self):
|
||||
"""
|
||||
Internal default value for the assignment of this column. Defaults to ``empty``. Return ``None`` to disable this
|
||||
option.
|
||||
"""
|
||||
return 'empty'
|
||||
|
||||
@property
|
||||
def default_label(self):
|
||||
"""
|
||||
Human-readable description of the default assignment of this column, defaults to "Keep empty".
|
||||
"""
|
||||
return gettext_lazy('Keep empty')
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
|
||||
def static_choices(self):
|
||||
"""
|
||||
This will be called when rendering the form component and allows you to return a list of values that can be
|
||||
selected by the user statically during import.
|
||||
|
||||
:return: list of 2-tuples of strings
|
||||
"""
|
||||
return []
|
||||
|
||||
def resolve(self, settings, record):
|
||||
"""
|
||||
This method will be called to get the raw value for this field, usually by either using a static value or
|
||||
inspecting the CSV file for the assigned header. You usually do not need to implement this on your own,
|
||||
the default should be fine.
|
||||
"""
|
||||
k = settings.get(self.identifier, self.default_value)
|
||||
if k == self.default_value:
|
||||
return None
|
||||
elif k.startswith('csv:'):
|
||||
return record.get(k[4:], None) or None
|
||||
elif k.startswith('static:'):
|
||||
return k[7:]
|
||||
raise ValidationError(_('Invalid setting for column "{header}".').format(header=self.verbose_name))
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
"""
|
||||
Allows you to validate the raw input value for your column. Raise ``ValidationError`` if the value is invalid.
|
||||
You do not need to include the column or row name or value in the error message as it will automatically be
|
||||
included.
|
||||
|
||||
:param value: Contains the raw value of your column as returned by ``resolve``. This can usually be ``None``,
|
||||
e.g. if the column is empty or does not exist in this row.
|
||||
:param previous_values: Dictionary containing the validated values of all columns that have already been validated.
|
||||
"""
|
||||
return value
|
||||
|
||||
def assign(self, value, obj, **kwargs):
|
||||
"""
|
||||
This will be called to perform the actual import. You are supposed to set attributes on the ``obj`` or other
|
||||
related objects that get passed in based on the input ``value``. This is called *before* the actual database
|
||||
transaction, so the input objects do not yet have a primary key. If you want to create related objects, you
|
||||
need to place them into some sort of internal queue and persist them when ``save`` is called.
|
||||
"""
|
||||
pass
|
||||
|
||||
def save(self, obj):
|
||||
"""
|
||||
This will be called to perform the actual import. This is called inside the actual database transaction and the
|
||||
input object ``obj`` has already been saved to the database.
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def timezone(self):
|
||||
return self.event.timezone
|
||||
|
||||
|
||||
def i18n_flat(l):
|
||||
if isinstance(l.data, dict):
|
||||
return l.data.values()
|
||||
return [l.data]
|
||||
|
||||
|
||||
class BooleanColumnMixin:
|
||||
default_value = None
|
||||
initial = "static:false"
|
||||
|
||||
def static_choices(self):
|
||||
return (
|
||||
("false", _("No")),
|
||||
("true", _("Yes")),
|
||||
)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
return False
|
||||
|
||||
if value.lower() in ("true", "1", "yes", _("Yes").lower()):
|
||||
return True
|
||||
elif value.lower() in ("false", "0", "no", _("No").lower()):
|
||||
return False
|
||||
else:
|
||||
raise ValidationError(_("Could not parse {value} as a yes/no value.").format(value=value))
|
||||
|
||||
|
||||
class DatetimeColumnMixin:
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
return
|
||||
|
||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||
for format in input_formats:
|
||||
try:
|
||||
d = datetime.datetime.strptime(value, format)
|
||||
d = d.replace(tzinfo=self.timezone)
|
||||
return d
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
|
||||
|
||||
|
||||
class DecimalColumnMixin:
|
||||
def clean(self, value, previous_values):
|
||||
if value not in (None, ''):
|
||||
value = formats.sanitize_separators(re.sub(r'[^0-9.,-]', '', value))
|
||||
try:
|
||||
value = Decimal(value)
|
||||
except (DecimalException, TypeError):
|
||||
raise ValidationError(_('You entered an invalid number.'))
|
||||
return value
|
||||
|
||||
|
||||
class IntegerColumnMixin:
|
||||
def clean(self, value, previous_values):
|
||||
if value is not None:
|
||||
validate_integer(value)
|
||||
return int(value)
|
||||
|
||||
|
||||
class SubeventColumnMixin:
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._subevent_cache = {}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@cached_property
|
||||
def subevents(self):
|
||||
return list(self.event.subevents.filter(active=True).order_by('date_from'))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(p.pk), str(p)) for p in self.subevents
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value in self._subevent_cache:
|
||||
return self._subevent_cache[value]
|
||||
|
||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||
for format in input_formats:
|
||||
try:
|
||||
d = datetime.datetime.strptime(value, format)
|
||||
d = d.replace(tzinfo=self.event.timezone)
|
||||
try:
|
||||
se = self.event.subevents.get(
|
||||
active=True,
|
||||
date_from__gt=d - datetime.timedelta(seconds=1),
|
||||
date_from__lt=d + datetime.timedelta(seconds=1),
|
||||
)
|
||||
self._subevent_cache[value] = se
|
||||
return se
|
||||
except SubEvent.DoesNotExist:
|
||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||
except SubEvent.MultipleObjectsReturned:
|
||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
matches = [
|
||||
p for p in self.subevents
|
||||
if str(p.pk) == value or any(
|
||||
(v and v == value) for v in i18n_flat(p.name)) or p.date_from.isoformat() == value
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||
|
||||
self._subevent_cache[value] = matches[0]
|
||||
return matches[0]
|
||||
@@ -20,9 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import datetime
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal, DecimalException
|
||||
|
||||
import pycountry
|
||||
from django.conf import settings
|
||||
@@ -42,9 +40,13 @@ from phonenumbers import SUPPORTED_REGIONS
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.forms.questions import guess_country
|
||||
from pretix.base.modelimport import (
|
||||
DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, SubeventColumnMixin,
|
||||
i18n_flat,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Customer, ItemVariation, OrderPosition, Question, QuestionAnswer,
|
||||
QuestionOption, Seat, SubEvent,
|
||||
QuestionOption, Seat,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.settings import (
|
||||
@@ -53,99 +55,6 @@ from pretix.base.settings import (
|
||||
from pretix.base.signals import order_import_columns
|
||||
|
||||
|
||||
class ImportColumn:
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
Unique, internal name of the column.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
"""
|
||||
Human-readable description of the column
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
"""
|
||||
Initial value for the form component
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def default_value(self):
|
||||
"""
|
||||
Internal default value for the assignment of this column. Defaults to ``empty``. Return ``None`` to disable this
|
||||
option.
|
||||
"""
|
||||
return 'empty'
|
||||
|
||||
@property
|
||||
def default_label(self):
|
||||
"""
|
||||
Human-readable description of the default assignment of this column, defaults to "Keep empty".
|
||||
"""
|
||||
return gettext_lazy('Keep empty')
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
|
||||
def static_choices(self):
|
||||
"""
|
||||
This will be called when rendering the form component and allows you to return a list of values that can be
|
||||
selected by the user statically during import.
|
||||
|
||||
:return: list of 2-tuples of strings
|
||||
"""
|
||||
return []
|
||||
|
||||
def resolve(self, settings, record):
|
||||
"""
|
||||
This method will be called to get the raw value for this field, usually by either using a static value or
|
||||
inspecting the CSV file for the assigned header. You usually do not need to implement this on your own,
|
||||
the default should be fine.
|
||||
"""
|
||||
k = settings.get(self.identifier, self.default_value)
|
||||
if k == self.default_value:
|
||||
return None
|
||||
elif k.startswith('csv:'):
|
||||
return record.get(k[4:], None) or None
|
||||
elif k.startswith('static:'):
|
||||
return k[7:]
|
||||
raise ValidationError(_('Invalid setting for column "{header}".').format(header=self.verbose_name))
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
"""
|
||||
Allows you to validate the raw input value for your column. Raise ``ValidationError`` if the value is invalid.
|
||||
You do not need to include the column or row name or value in the error message as it will automatically be
|
||||
included.
|
||||
|
||||
:param value: Contains the raw value of your column as returned by ``resolve``. This can usually be ``None``,
|
||||
e.g. if the column is empty or does not exist in this row.
|
||||
:param previous_values: Dictionary containing the validated values of all columns that have already been validated.
|
||||
"""
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
"""
|
||||
This will be called to perform the actual import. You are supposed to set attributes on the ``order``, ``position``,
|
||||
or ``invoice_address`` objects based on the input ``value``. This is called *before* the actual database
|
||||
transaction, so these three objects do not yet have a primary key. If you want to create related objects, you
|
||||
need to place them into some sort of internal queue and persist them when ``save`` is called.
|
||||
"""
|
||||
pass
|
||||
|
||||
def save(self, order):
|
||||
"""
|
||||
This will be called to perform the actual import. This is called inside the actual database transaction and the
|
||||
input object ``order`` has already been saved to the database.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class EmailColumn(ImportColumn):
|
||||
identifier = 'email'
|
||||
verbose_name = gettext_lazy('E-mail address')
|
||||
@@ -182,74 +91,20 @@ class PhoneColumn(ImportColumn):
|
||||
order.phone = value
|
||||
|
||||
|
||||
class SubeventColumn(ImportColumn):
|
||||
class SubeventColumn(SubeventColumnMixin, ImportColumn):
|
||||
identifier = 'subevent'
|
||||
verbose_name = pgettext_lazy('subevents', 'Date')
|
||||
default_value = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._subevent_cache = {}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@cached_property
|
||||
def subevents(self):
|
||||
return list(self.event.subevents.filter(active=True).order_by('date_from'))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(p.pk), str(p)) for p in self.subevents
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
raise ValidationError(pgettext("subevent", "You need to select a date."))
|
||||
|
||||
if value in self._subevent_cache:
|
||||
return self._subevent_cache[value]
|
||||
|
||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||
for format in input_formats:
|
||||
try:
|
||||
d = datetime.datetime.strptime(value, format)
|
||||
d = d.replace(tzinfo=self.event.timezone)
|
||||
try:
|
||||
se = self.event.subevents.get(
|
||||
active=True,
|
||||
date_from__gt=d - datetime.timedelta(seconds=1),
|
||||
date_from__lt=d + datetime.timedelta(seconds=1),
|
||||
)
|
||||
self._subevent_cache[value] = se
|
||||
return se
|
||||
except SubEvent.DoesNotExist:
|
||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||
except SubEvent.MultipleObjectsReturned:
|
||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
matches = [
|
||||
p for p in self.subevents
|
||||
if str(p.pk) == value or any(
|
||||
(v and v == value) for v in i18n_flat(p.name)) or p.date_from.isoformat() == value
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||
|
||||
self._subevent_cache[value] = matches[0]
|
||||
return matches[0]
|
||||
return super().clean(value, previous_values)
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.subevent = value
|
||||
|
||||
|
||||
def i18n_flat(l):
|
||||
if isinstance(l.data, dict):
|
||||
return l.data.values()
|
||||
return [l.data]
|
||||
|
||||
|
||||
class ItemColumn(ImportColumn):
|
||||
identifier = 'item'
|
||||
verbose_name = gettext_lazy('Product')
|
||||
@@ -572,20 +427,11 @@ class AttendeeState(ImportColumn):
|
||||
position.state = value or ''
|
||||
|
||||
|
||||
class Price(ImportColumn):
|
||||
class Price(DecimalColumnMixin, ImportColumn):
|
||||
identifier = 'price'
|
||||
verbose_name = gettext_lazy('Price')
|
||||
default_label = gettext_lazy('Calculate from product')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value not in (None, ''):
|
||||
value = formats.sanitize_separators(re.sub(r'[^0-9.,-]', '', value))
|
||||
try:
|
||||
value = Decimal(value)
|
||||
except (DecimalException, TypeError):
|
||||
raise ValidationError(_('You entered an invalid number.'))
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
if value is None:
|
||||
p = get_price(position.item, position.variation, position.voucher, subevent=position.subevent,
|
||||
@@ -649,50 +495,44 @@ class Locale(ImportColumn):
|
||||
order.locale = value
|
||||
|
||||
|
||||
class ValidFrom(ImportColumn):
|
||||
class ValidFrom(DatetimeColumnMixin, ImportColumn):
|
||||
identifier = 'valid_from'
|
||||
verbose_name = gettext_lazy('Valid from')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
return
|
||||
|
||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||
for format in input_formats:
|
||||
try:
|
||||
d = datetime.datetime.strptime(value, format)
|
||||
d = d.replace(tzinfo=self.event.timezone)
|
||||
return d
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.valid_from = value
|
||||
|
||||
|
||||
class ValidUntil(ImportColumn):
|
||||
class ValidUntil(DatetimeColumnMixin, ImportColumn):
|
||||
identifier = 'valid_until'
|
||||
verbose_name = gettext_lazy('Valid until')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.valid_until = value
|
||||
|
||||
|
||||
class Expires(DatetimeColumnMixin, ImportColumn):
|
||||
identifier = 'expires'
|
||||
verbose_name = gettext_lazy('Expiry date')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
return
|
||||
|
||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||
input_formats = formats.get_format('DATE_INPUT_FORMATS', use_l10n=True)
|
||||
for format in input_formats:
|
||||
try:
|
||||
d = datetime.datetime.strptime(value, format)
|
||||
d = d.replace(tzinfo=self.event.timezone)
|
||||
d = d.replace(tzinfo=self.timezone, hour=23, minute=59, second=59)
|
||||
return d
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
|
||||
return super().clean(value, previous_values) # parse date
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.valid_until = value
|
||||
if value:
|
||||
order.expires = value
|
||||
|
||||
|
||||
class Saleschannel(ImportColumn):
|
||||
@@ -849,7 +689,7 @@ class CustomerColumn(ImportColumn):
|
||||
order.customer = value
|
||||
|
||||
|
||||
def get_all_columns(event):
|
||||
def get_order_import_columns(event):
|
||||
default = []
|
||||
if event.has_subevents:
|
||||
default.append(SubeventColumn(event))
|
||||
@@ -888,12 +728,13 @@ def get_all_columns(event):
|
||||
AttendeeState(event),
|
||||
Price(event),
|
||||
Secret(event),
|
||||
Locale(event),
|
||||
Saleschannel(event),
|
||||
SeatColumn(event),
|
||||
Comment(event),
|
||||
ValidFrom(event),
|
||||
ValidUntil(event),
|
||||
Locale(event),
|
||||
Saleschannel(event),
|
||||
Expires(event),
|
||||
Comment(event),
|
||||
]
|
||||
for q in event.questions.prefetch_related('options').exclude(type=Question.TYPE_FILE):
|
||||
default.append(QuestionColumn(event, q))
|
||||
385
src/pretix/base/modelimport_vouchers.py
Normal file
385
src/pretix/base/modelimport_vouchers.py
Normal file
@@ -0,0 +1,385 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
|
||||
|
||||
from pretix.base.modelimport import (
|
||||
BooleanColumnMixin, DatetimeColumnMixin, DecimalColumnMixin, ImportColumn,
|
||||
IntegerColumnMixin, i18n_flat,
|
||||
)
|
||||
from pretix.base.models import ItemVariation, Quota, Seat, Voucher
|
||||
from pretix.base.signals import voucher_import_columns
|
||||
|
||||
|
||||
class CodeColumn(ImportColumn):
|
||||
identifier = 'code'
|
||||
verbose_name = gettext_lazy('Voucher code')
|
||||
default_value = None
|
||||
|
||||
def __init__(self, *args):
|
||||
self._cached = set()
|
||||
super().__init__(*args)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
MinLengthValidator(5)(value)
|
||||
if value and (value in self._cached or Voucher.objects.filter(event=self.event, code=value).exists()):
|
||||
raise ValidationError(_('A voucher with this code already exists.'))
|
||||
self._cached.add(value)
|
||||
return value
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.code = value
|
||||
|
||||
|
||||
class SubeventColumn(ImportColumn):
|
||||
identifier = 'subevent'
|
||||
verbose_name = pgettext_lazy('subevents', 'Date')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.subevent = value
|
||||
|
||||
|
||||
class MaxUsagesColumn(IntegerColumnMixin, ImportColumn):
|
||||
identifier = 'max_usages'
|
||||
verbose_name = gettext_lazy('Maximum usages')
|
||||
default_value = None
|
||||
initial = "static:1"
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
("1", "1")
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value is None and previous_values.get("code"):
|
||||
raise ValidationError(_('The maximum number of usages must be set.'))
|
||||
return super().clean(value, previous_values)
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.max_usages = value if value is not None else 1
|
||||
|
||||
|
||||
class MinUsagesColumn(IntegerColumnMixin, ImportColumn):
|
||||
identifier = 'min_usages'
|
||||
verbose_name = gettext_lazy('Minimum usages')
|
||||
default_value = None
|
||||
initial = "static:1"
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
("1", "1")
|
||||
]
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.min_usages = value if value is not None else 1
|
||||
|
||||
|
||||
class BudgetColumn(DecimalColumnMixin, ImportColumn):
|
||||
identifier = 'budget'
|
||||
verbose_name = gettext_lazy('Maximum discount budget')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.budget = value
|
||||
|
||||
|
||||
class ValidUntilColumn(DatetimeColumnMixin, ImportColumn):
|
||||
identifier = 'valid_until'
|
||||
verbose_name = gettext_lazy('Valid until')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.valid_until = value
|
||||
|
||||
|
||||
class BlockQuotaColumn(BooleanColumnMixin, ImportColumn):
|
||||
identifier = 'block_quota'
|
||||
verbose_name = gettext_lazy('Reserve ticket from quota')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.block_quota = value
|
||||
|
||||
|
||||
class AllowIgnoreQuotaColumn(BooleanColumnMixin, ImportColumn):
|
||||
identifier = 'allow_ignore_quota'
|
||||
verbose_name = gettext_lazy('Allow to bypass quota')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.allow_ignore_quota = value
|
||||
|
||||
|
||||
class PriceModeColumn(ImportColumn):
|
||||
identifier = 'price_mode'
|
||||
verbose_name = gettext_lazy('Price mode')
|
||||
default_value = None
|
||||
initial = 'static:none'
|
||||
|
||||
def static_choices(self):
|
||||
return Voucher.PRICE_MODES
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
d = dict(Voucher.PRICE_MODES)
|
||||
reverse = {v: k for k, v in Voucher.PRICE_MODES}
|
||||
if value in d:
|
||||
return value
|
||||
elif value in reverse:
|
||||
return reverse[value]
|
||||
else:
|
||||
raise ValidationError(_("Could not parse {value} as a price mode, use one of {options}.").format(
|
||||
value=value, options=', '.join(d.keys())
|
||||
))
|
||||
|
||||
def assign(self, value, voucher: Voucher, **kwargs):
|
||||
voucher.price_mode = value
|
||||
|
||||
|
||||
class ValueColumn(DecimalColumnMixin, ImportColumn):
|
||||
identifier = 'value'
|
||||
verbose_name = gettext_lazy('Voucher value')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
value = super().clean(value, previous_values)
|
||||
if value and previous_values.get("price_mode") == "none":
|
||||
raise ValidationError(_("It is pointless to set a value without a price mode."))
|
||||
return value
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.value = value or Decimal("0.00")
|
||||
|
||||
|
||||
class ItemColumn(ImportColumn):
|
||||
identifier = 'item'
|
||||
verbose_name = gettext_lazy('Product')
|
||||
|
||||
@cached_property
|
||||
def items(self):
|
||||
return list(self.event.items.filter(active=True))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(p.pk), str(p)) for p in self.items
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
return
|
||||
matches = [
|
||||
p for p in self.items
|
||||
if str(p.pk) == value or (p.internal_name and p.internal_name == value) or any(
|
||||
(v and v == value) for v in i18n_flat(p.name))
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(_("No matching product was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(_("Multiple matching products were found."))
|
||||
return matches[0]
|
||||
|
||||
def assign(self, value, voucher, **kwargs):
|
||||
voucher.item = value
|
||||
|
||||
|
||||
class VariationColumn(ImportColumn):
|
||||
identifier = 'variation'
|
||||
verbose_name = gettext_lazy('Product variation')
|
||||
|
||||
@cached_property
|
||||
def items(self):
|
||||
return list(ItemVariation.objects.filter(
|
||||
active=True, item__active=True, item__event=self.event
|
||||
).select_related('item'))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(p.pk), '{} – {}'.format(p.item, p.value)) for p in self.items
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
matches = [
|
||||
p for p in self.items
|
||||
if (str(p.pk) == value or any((v and v == value) for v in i18n_flat(p.value))) and p.item_id == previous_values['item'].pk
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(_("No matching variation was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(_("Multiple matching variations were found."))
|
||||
return matches[0]
|
||||
return value
|
||||
|
||||
def assign(self, value, voucher: Voucher, **kwargs):
|
||||
voucher.variation = value
|
||||
|
||||
|
||||
class QuotaColumn(ImportColumn):
|
||||
identifier = 'quota'
|
||||
verbose_name = gettext_lazy('Quota')
|
||||
|
||||
@cached_property
|
||||
def quotas(self):
|
||||
return list(Quota.objects.filter(
|
||||
event=self.event
|
||||
))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(q.pk), q.name) for q in self.quotas
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
if previous_values.get('item'):
|
||||
raise ValidationError(_("You cannot specify a quota if you specified a product."))
|
||||
matches = [
|
||||
q for q in self.quotas
|
||||
if str(q.pk) == value or q.name == value
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(_("No matching variation was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(_("Multiple matching variations were found."))
|
||||
|
||||
return matches[0]
|
||||
return value
|
||||
|
||||
def assign(self, value, voucher: Voucher, **kwargs):
|
||||
voucher.quota = value
|
||||
|
||||
|
||||
class SeatColumn(ImportColumn):
|
||||
identifier = 'seat'
|
||||
verbose_name = gettext_lazy('Seat ID')
|
||||
|
||||
def __init__(self, *args):
|
||||
self._cached = set()
|
||||
super().__init__(*args)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
if self.event.has_subevents:
|
||||
if not previous_values.get('subevent'):
|
||||
raise ValidationError(_('You need to choose a date if you select a seat.'))
|
||||
|
||||
try:
|
||||
value = Seat.objects.get(
|
||||
event=self.event,
|
||||
seat_guid=value,
|
||||
subevent=previous_values.get('subevent')
|
||||
)
|
||||
except Seat.MultipleObjectsReturned:
|
||||
raise ValidationError(_('Multiple matching seats were found.'))
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError(_('No matching seat was found.'))
|
||||
if not value.is_available() or value in self._cached:
|
||||
raise ValidationError(
|
||||
_('The seat you selected has already been taken. Please select a different seat.'))
|
||||
|
||||
if previous_values.get("quota"):
|
||||
raise ValidationError(_('You need to choose a specific product if you select a seat.'))
|
||||
|
||||
if previous_values.get('max_usages', 1) > 1 or previous_values.get('min_usages', 1) > 1:
|
||||
raise ValidationError(_('Seat-specific vouchers can only be used once.'))
|
||||
|
||||
if previous_values.get("item") and value.product != previous_values.get("item"):
|
||||
raise ValidationError(
|
||||
_('You need to choose the product "{prod}" for this seat.').format(prod=value.product)
|
||||
)
|
||||
|
||||
self._cached.add(value)
|
||||
return value
|
||||
|
||||
def assign(self, value, voucher: Voucher, **kwargs):
|
||||
voucher.seat = value
|
||||
|
||||
|
||||
class TagColumn(ImportColumn):
|
||||
identifier = 'tag'
|
||||
verbose_name = gettext_lazy('Tag')
|
||||
|
||||
def assign(self, value, voucher: Voucher, **kwargs):
|
||||
voucher.tag = value or ''
|
||||
|
||||
|
||||
class CommentColumn(ImportColumn):
|
||||
identifier = 'comment'
|
||||
verbose_name = gettext_lazy('Comment')
|
||||
|
||||
def assign(self, value, voucher: Voucher, **kwargs):
|
||||
voucher.comment = value or ''
|
||||
|
||||
|
||||
class ShowHiddenItemsColumn(BooleanColumnMixin, ImportColumn):
|
||||
identifier = 'show_hidden_items'
|
||||
verbose_name = gettext_lazy('Shows hidden products that match this voucher')
|
||||
initial = "static:true"
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.show_hidden_items = value
|
||||
|
||||
|
||||
class AllAddonsIncludedColumn(BooleanColumnMixin, ImportColumn):
|
||||
identifier = 'all_addons_included'
|
||||
verbose_name = gettext_lazy('Offer all add-on products for free when redeeming this voucher')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.all_addons_included = value
|
||||
|
||||
|
||||
class AllBundlesIncludedColumn(BooleanColumnMixin, ImportColumn):
|
||||
identifier = 'all_bundles_included'
|
||||
verbose_name = gettext_lazy('Include all bundled products without a designated price when redeeming this voucher')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.all_bundles_included = value
|
||||
|
||||
|
||||
def get_voucher_import_columns(event):
|
||||
default = []
|
||||
if event.has_subevents:
|
||||
default.append(SubeventColumn(event))
|
||||
default += [
|
||||
CodeColumn(event),
|
||||
MaxUsagesColumn(event),
|
||||
MinUsagesColumn(event),
|
||||
BudgetColumn(event),
|
||||
ValidUntilColumn(event),
|
||||
BlockQuotaColumn(event),
|
||||
AllowIgnoreQuotaColumn(event),
|
||||
PriceModeColumn(event),
|
||||
ValueColumn(event),
|
||||
ItemColumn(event),
|
||||
VariationColumn(event),
|
||||
QuotaColumn(event),
|
||||
SeatColumn(event),
|
||||
TagColumn(event),
|
||||
CommentColumn(event),
|
||||
ShowHiddenItemsColumn(event),
|
||||
AllAddonsIncludedColumn(event),
|
||||
AllBundlesIncludedColumn(event),
|
||||
]
|
||||
|
||||
for recv, resp in voucher_import_columns.send(sender=event):
|
||||
default += resp
|
||||
|
||||
return default
|
||||
@@ -37,9 +37,7 @@ import json
|
||||
import operator
|
||||
from datetime import timedelta
|
||||
from functools import reduce
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import webauthn
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser, BaseUserManager, PermissionsMixin,
|
||||
@@ -53,13 +51,13 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_otp.models import Device
|
||||
from django_scopes import scopes_disabled
|
||||
from u2flib_server.utils import (
|
||||
pub_key_from_der, websafe_decode, websafe_encode,
|
||||
)
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
from ...helpers.countries import FastCountryField
|
||||
from ...helpers.u2f import pub_key_from_der, websafe_decode
|
||||
from .base import LoggingMixin
|
||||
|
||||
|
||||
@@ -420,18 +418,22 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
else:
|
||||
return set()
|
||||
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||
"""
|
||||
Checks if this user is part of any team that grants access of type ``perm_name``
|
||||
to the event ``event``.
|
||||
|
||||
Either ``request`` or ``session_key`` are required to detect staff sessions properly.
|
||||
|
||||
:param organizer: The organizer of the event
|
||||
:param event: The event to check
|
||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
||||
:param request: The current request (optional). Required to detect staff sessions properly.
|
||||
:param request: The current request (optional)
|
||||
:param session_key: The current session key (optional)
|
||||
:return: bool
|
||||
"""
|
||||
if request and self.has_active_staff_session(request.session.session_key):
|
||||
assert not (session_key and request)
|
||||
if (session_key or request) and self.has_active_staff_session(session_key or request.session.session_key):
|
||||
return True
|
||||
teams = self._get_teams_for_event(organizer, event)
|
||||
if teams:
|
||||
@@ -582,6 +584,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
self.save(update_fields=['session_token'])
|
||||
|
||||
|
||||
class UserKnownLoginSource(models.Model):
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
|
||||
agent_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
device_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
os_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
country = FastCountryField(null=True, blank=True)
|
||||
last_seen = models.DateTimeField()
|
||||
|
||||
|
||||
class StaffSession(models.Model):
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
date_start = models.DateTimeField(auto_now_add=True)
|
||||
@@ -608,7 +619,12 @@ class U2FDevice(Device):
|
||||
json_data = models.TextField()
|
||||
|
||||
@property
|
||||
def webauthnuser(self):
|
||||
def webauthndevice(self):
|
||||
d = json.loads(self.json_data)
|
||||
return PublicKeyCredentialDescriptor(websafe_decode(d['keyHandle']))
|
||||
|
||||
@property
|
||||
def webauthnpubkey(self):
|
||||
d = json.loads(self.json_data)
|
||||
# We manually need to convert the pubkey from DER format (used in our
|
||||
# former U2F implementation) to the format required by webauthn. This
|
||||
@@ -620,16 +636,7 @@ class U2FDevice(Device):
|
||||
pub_key.public_numbers().x, pub_key.public_numbers().y
|
||||
)
|
||||
)
|
||||
return webauthn.WebAuthnUser(
|
||||
d['keyHandle'],
|
||||
self.user.email,
|
||||
str(self.user),
|
||||
settings.SITE_URL,
|
||||
d['keyHandle'],
|
||||
websafe_encode(pub_key),
|
||||
1,
|
||||
urlparse(settings.SITE_URL).netloc
|
||||
)
|
||||
return pub_key
|
||||
|
||||
|
||||
class WebAuthnDevice(Device):
|
||||
@@ -641,14 +648,9 @@ class WebAuthnDevice(Device):
|
||||
sign_count = models.IntegerField(default=0)
|
||||
|
||||
@property
|
||||
def webauthnuser(self):
|
||||
return webauthn.WebAuthnUser(
|
||||
self.ukey,
|
||||
self.user.email,
|
||||
str(self.user),
|
||||
settings.SITE_URL,
|
||||
self.credential_id,
|
||||
self.pub_key,
|
||||
self.sign_count,
|
||||
urlparse(settings.SITE_URL).netloc
|
||||
)
|
||||
def webauthndevice(self):
|
||||
return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id))
|
||||
|
||||
@property
|
||||
def webauthnpubkey(self):
|
||||
return websafe_decode(self.pub_key)
|
||||
|
||||
@@ -285,7 +285,7 @@ class CheckinList(LoggedModel):
|
||||
}
|
||||
allowed_vars = {
|
||||
'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days',
|
||||
'minutes_since_last_entry', 'minutes_since_first_entry', 'gate',
|
||||
'minutes_since_last_entry', 'minutes_since_first_entry', 'gate', 'entry_status',
|
||||
}
|
||||
if not rules or not isinstance(rules, dict):
|
||||
return rules
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from itertools import groupby
|
||||
from math import ceil
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -272,7 +273,7 @@ class Discount(LoggedModel):
|
||||
|
||||
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
||||
# want to match multiples of 3
|
||||
n_groups = min(len(condition_idx_group) // self.condition_min_count, len(benefit_idx_group))
|
||||
n_groups = min(len(condition_idx_group) // self.condition_min_count, ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches))
|
||||
consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
|
||||
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
|
||||
else:
|
||||
|
||||
@@ -45,6 +45,7 @@ from zoneinfo import ZoneInfo
|
||||
import pytz_deprecation_shim
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files import File
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import (
|
||||
@@ -67,6 +68,7 @@ from i18nfield.fields import I18nCharField, I18nTextField
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
from pretix.base.validators import EventSlugBanlistValidator
|
||||
from pretix.helpers.database import GroupConcat
|
||||
from pretix.helpers.daterange import daterange
|
||||
@@ -229,17 +231,25 @@ class EventMixin:
|
||||
else:
|
||||
return self.presale_end
|
||||
|
||||
@property
|
||||
def waiting_list_active(self):
|
||||
if not self.settings.waiting_list_enabled:
|
||||
return False
|
||||
if self.settings.waiting_list_auto_disable:
|
||||
return self.settings.waiting_list_auto_disable.datetime(self) > time_machine_now()
|
||||
return True
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
"""
|
||||
Is true, when ``presale_end`` is set and in the past.
|
||||
"""
|
||||
if self.effective_presale_end:
|
||||
return now() > self.effective_presale_end
|
||||
return time_machine_now() > self.effective_presale_end
|
||||
elif self.date_to:
|
||||
return now() > self.date_to
|
||||
return time_machine_now() > self.date_to
|
||||
else:
|
||||
return now().astimezone(self.timezone).date() > self.date_from.astimezone(self.timezone).date()
|
||||
return time_machine_now().astimezone(self.timezone).date() > self.date_from.astimezone(self.timezone).date()
|
||||
|
||||
@property
|
||||
def effective_presale_start(self):
|
||||
@@ -259,12 +269,15 @@ class EventMixin:
|
||||
Is true, when ``presale_end`` is not set or in the future and ``presale_start`` is not
|
||||
set or in the past.
|
||||
"""
|
||||
if self.effective_presale_start and now() < self.effective_presale_start:
|
||||
if self.effective_presale_start and time_machine_now() < self.effective_presale_start:
|
||||
return False
|
||||
return not self.presale_has_ended
|
||||
|
||||
@property
|
||||
def event_microdata(self):
|
||||
if self.settings.event_microdata:
|
||||
return self.settings.event_microdata
|
||||
|
||||
import json
|
||||
|
||||
eventdict = {
|
||||
@@ -304,11 +317,11 @@ class EventMixin:
|
||||
q_variation = (
|
||||
Q(active=True)
|
||||
& Q(sales_channels__contains=channel)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()))
|
||||
& Q(item__active=True)
|
||||
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
|
||||
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
|
||||
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=time_machine_now()))
|
||||
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=time_machine_now()))
|
||||
& Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False))
|
||||
& Q(item__sales_channels__contains=channel)
|
||||
& Q(item__require_bundling=False)
|
||||
@@ -683,7 +696,7 @@ class Event(EventMixin, LoggedModel):
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
if self.has_subevents:
|
||||
return self.presale_end and now() > self.presale_end
|
||||
return self.presale_end and time_machine_now() > self.presale_end
|
||||
else:
|
||||
return super().presale_has_ended
|
||||
|
||||
@@ -1014,7 +1027,7 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
s.object = self
|
||||
s.pk = None
|
||||
if s.value.startswith('file://'):
|
||||
if s.value.startswith('file://') and settings_hierarkey.get_declared_type(s.key) == File:
|
||||
fi = default_storage.open(s.value[len('file://'):], 'rb')
|
||||
nonce = get_random_string(length=8)
|
||||
fname_base = clean_filename(os.path.basename(s.value))
|
||||
@@ -1176,8 +1189,8 @@ class Event(EventMixin, LoggedModel):
|
||||
)
|
||||
).filter(
|
||||
Q(active=True) & Q(is_public=True) & (
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now() - timedelta(hours=24)))
|
||||
| Q(date_to__gte=now() - timedelta(hours=24))
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=time_machine_now() - timedelta(hours=24)))
|
||||
| Q(date_to__gte=time_machine_now() - timedelta(hours=24))
|
||||
)
|
||||
) # order_by doesn't make sense with I18nField
|
||||
if ordering in ("date_ascending", "date_descending"):
|
||||
@@ -1226,6 +1239,9 @@ class Event(EventMixin, LoggedModel):
|
||||
if self.has_paid_things and not self.has_payment_provider:
|
||||
issues.append(_('You have configured at least one paid product but have not enabled any payment methods.'))
|
||||
|
||||
if self.has_paid_things and self.currency == "XXX":
|
||||
issues.append(_('You have configured at least one paid product but have not configured a currency.'))
|
||||
|
||||
if not self.quotas.exists():
|
||||
issues.append(_('You need to configure at least one quota to sell anything.'))
|
||||
|
||||
@@ -1447,13 +1463,17 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
)
|
||||
frontpage_text = I18nTextField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Frontpage text")
|
||||
verbose_name=_("Frontpage text"),
|
||||
)
|
||||
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
||||
related_name='subevents', verbose_name=_('Seating plan'))
|
||||
|
||||
items = models.ManyToManyField('Item', through='SubEventItem')
|
||||
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
|
||||
comment = models.TextField(
|
||||
verbose_name=_("Internal comment"),
|
||||
null=True, blank=True
|
||||
)
|
||||
|
||||
last_modified = models.DateTimeField(
|
||||
auto_now=True, db_index=True
|
||||
@@ -1490,7 +1510,7 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
disabled_items=Coalesce(
|
||||
Subquery(
|
||||
SubEventItem.objects.filter(
|
||||
Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()),
|
||||
Q(disabled=True) | Q(available_from__gt=time_machine_now()) | Q(available_until__lt=time_machine_now()),
|
||||
subevent=OuterRef('pk'),
|
||||
).order_by().values('subevent').annotate(items=GroupConcat('item_id', delimiter=',')).values('items'),
|
||||
output_field=models.TextField(),
|
||||
@@ -1501,7 +1521,7 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
disabled_vars=Coalesce(
|
||||
Subquery(
|
||||
SubEventItemVariation.objects.filter(
|
||||
Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()),
|
||||
Q(disabled=True) | Q(available_from__gt=time_machine_now()) | Q(available_until__lt=time_machine_now()),
|
||||
subevent=OuterRef('pk'),
|
||||
).order_by().values('subevent').annotate(items=GroupConcat('variation_id', delimiter=',')).values('items'),
|
||||
output_field=models.TextField(),
|
||||
|
||||
@@ -55,7 +55,7 @@ from django.db.models import Q
|
||||
from django.utils import formats
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import is_naive, make_aware, now
|
||||
from django.utils.timezone import is_naive, make_aware
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries.fields import Country
|
||||
from django_scopes import ScopedManager
|
||||
@@ -65,6 +65,7 @@ from pretix.base.models import fields
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
|
||||
from ...helpers.images import ImageSizeValidator
|
||||
from ..media import MEDIA_TYPES
|
||||
@@ -192,7 +193,7 @@ class SubEventItem(models.Model):
|
||||
self.subevent.event.cache.clear()
|
||||
|
||||
def is_available(self, now_dt: datetime=None) -> bool:
|
||||
now_dt = now_dt or now()
|
||||
now_dt = now_dt or time_machine_now()
|
||||
if self.disabled:
|
||||
return False
|
||||
if self.available_from and self.available_from > now_dt:
|
||||
@@ -248,7 +249,7 @@ class SubEventItemVariation(models.Model):
|
||||
self.subevent.event.cache.clear()
|
||||
|
||||
def is_available(self, now_dt: datetime=None) -> bool:
|
||||
now_dt = now_dt or now()
|
||||
now_dt = now_dt or time_machine_now()
|
||||
if self.disabled:
|
||||
return False
|
||||
if self.available_from and self.available_from > now_dt:
|
||||
@@ -263,8 +264,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
|
||||
# IMPORTANT: If this is updated, also update the ItemVariation query
|
||||
# in models/event.py: EventMixin.annotated()
|
||||
Q(active=True)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info'))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info'))
|
||||
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
|
||||
)
|
||||
if not allow_addons:
|
||||
@@ -374,6 +375,13 @@ class Item(LoggedModel):
|
||||
(VALIDITY_MODE_DYNAMIC, _('Dynamic validity')),
|
||||
)
|
||||
|
||||
UNAVAIL_MODE_HIDDEN = "hide"
|
||||
UNAVAIL_MODE_INFO = "info"
|
||||
UNAVAIL_MODES = (
|
||||
(UNAVAIL_MODE_HIDDEN, _("Hide product if unavailable")),
|
||||
(UNAVAIL_MODE_INFO, _("Show info text if unavailable")),
|
||||
)
|
||||
|
||||
MEDIA_POLICY_REUSE = 'reuse'
|
||||
MEDIA_POLICY_NEW = 'new'
|
||||
MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new'
|
||||
@@ -423,7 +431,7 @@ class Item(LoggedModel):
|
||||
help_text=_("If this product has multiple variations, you can set different prices for each of the "
|
||||
"variations. If a variation does not have a special price or if you do not have variations, "
|
||||
"this price will be used."),
|
||||
max_digits=13, decimal_places=2, null=True
|
||||
max_digits=13, decimal_places=2,
|
||||
)
|
||||
free_price = models.BooleanField(
|
||||
default=False,
|
||||
@@ -487,11 +495,21 @@ class Item(LoggedModel):
|
||||
null=True, blank=True,
|
||||
help_text=_('This product will not be sold before the given date.')
|
||||
)
|
||||
available_from_mode = models.CharField(
|
||||
choices=UNAVAIL_MODES,
|
||||
default=UNAVAIL_MODE_HIDDEN,
|
||||
max_length=16,
|
||||
)
|
||||
available_until = models.DateTimeField(
|
||||
verbose_name=_("Available until"),
|
||||
null=True, blank=True,
|
||||
help_text=_('This product will not be sold after the given date.')
|
||||
)
|
||||
available_until_mode = models.CharField(
|
||||
choices=UNAVAIL_MODES,
|
||||
default=UNAVAIL_MODE_HIDDEN,
|
||||
max_length=16,
|
||||
)
|
||||
hidden_if_available = models.ForeignKey(
|
||||
'Quota',
|
||||
null=True, blank=True,
|
||||
@@ -703,6 +721,8 @@ class Item(LoggedModel):
|
||||
return str(self.internal_name or self.name)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.hide_without_voucher:
|
||||
self.require_voucher = True
|
||||
super().save(*args, **kwargs)
|
||||
if self.event:
|
||||
self.event.cache.clear()
|
||||
@@ -763,7 +783,7 @@ class Item(LoggedModel):
|
||||
return t
|
||||
|
||||
def is_available_by_time(self, now_dt: datetime=None) -> bool:
|
||||
now_dt = now_dt or now()
|
||||
now_dt = now_dt or time_machine_now()
|
||||
if self.available_from and self.available_from > now_dt:
|
||||
return False
|
||||
if self.available_until and self.available_until < now_dt:
|
||||
@@ -775,11 +795,29 @@ class Item(LoggedModel):
|
||||
Returns whether this item is available according to its ``active`` flag
|
||||
and its ``available_from`` and ``available_until`` fields
|
||||
"""
|
||||
now_dt = now_dt or now()
|
||||
now_dt = now_dt or time_machine_now()
|
||||
if not self.active or not self.is_available_by_time(now_dt):
|
||||
return False
|
||||
return True
|
||||
|
||||
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
|
||||
now_dt = now_dt or time_machine_now()
|
||||
subevent_item = subevent and subevent.item_overrides.get(self.pk)
|
||||
if not self.active:
|
||||
return 'active'
|
||||
elif self.available_from and self.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif self.available_until and self.available_until < now_dt:
|
||||
return 'available_until'
|
||||
elif (self.require_voucher or self.hide_without_voucher) and not has_voucher:
|
||||
return 'require_voucher'
|
||||
elif subevent_item and subevent_item.available_from and subevent_item.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt:
|
||||
return 'available_until'
|
||||
else:
|
||||
return None
|
||||
|
||||
def _get_quotas(self, ignored_quotas=None, subevent=None):
|
||||
check_quotas = set(getattr(
|
||||
self, '_subevent_quotas', # Utilize cache in product list
|
||||
@@ -920,11 +958,11 @@ class Item(LoggedModel):
|
||||
return self.validity_fixed_from, self.validity_fixed_until
|
||||
elif self.validity_mode == Item.VALIDITY_MODE_DYNAMIC:
|
||||
tz = override_tz or self.event.timezone
|
||||
requested_start = requested_start or now()
|
||||
requested_start = requested_start or time_machine_now()
|
||||
if enforce_start_limit and not self.validity_dynamic_start_choice:
|
||||
requested_start = now()
|
||||
requested_start = time_machine_now()
|
||||
if enforce_start_limit and self.validity_dynamic_start_choice_day_limit is not None:
|
||||
requested_start = min(requested_start, now() + timedelta(days=self.validity_dynamic_start_choice_day_limit))
|
||||
requested_start = min(requested_start, time_machine_now() + timedelta(days=self.validity_dynamic_start_choice_day_limit))
|
||||
|
||||
valid_until = requested_start.astimezone(tz)
|
||||
|
||||
@@ -1078,11 +1116,21 @@ class ItemVariation(models.Model):
|
||||
null=True, blank=True,
|
||||
help_text=_('This variation will not be sold before the given date.')
|
||||
)
|
||||
available_from_mode = models.CharField(
|
||||
choices=Item.UNAVAIL_MODES,
|
||||
default=Item.UNAVAIL_MODE_HIDDEN,
|
||||
max_length=16,
|
||||
)
|
||||
available_until = models.DateTimeField(
|
||||
verbose_name=_("Available until"),
|
||||
null=True, blank=True,
|
||||
help_text=_('This variation will not be sold after the given date.')
|
||||
)
|
||||
available_until_mode = models.CharField(
|
||||
choices=Item.UNAVAIL_MODES,
|
||||
default=Item.UNAVAIL_MODE_HIDDEN,
|
||||
max_length=16,
|
||||
)
|
||||
sales_channels = fields.MultiStringField(
|
||||
verbose_name=_('Sales channels'),
|
||||
default=_all_sales_channels_identifiers,
|
||||
@@ -1243,7 +1291,7 @@ class ItemVariation(models.Model):
|
||||
return ItemVariation.objects.filter(item=self.item).count() == 1
|
||||
|
||||
def is_available_by_time(self, now_dt: datetime=None) -> bool:
|
||||
now_dt = now_dt or now()
|
||||
now_dt = now_dt or time_machine_now()
|
||||
if self.available_from and self.available_from > now_dt:
|
||||
return False
|
||||
if self.available_until and self.available_until < now_dt:
|
||||
@@ -1255,11 +1303,27 @@ class ItemVariation(models.Model):
|
||||
Returns whether this item is available according to its ``active`` flag
|
||||
and its ``available_from`` and ``available_until`` fields
|
||||
"""
|
||||
now_dt = now_dt or now()
|
||||
now_dt = now_dt or time_machine_now()
|
||||
if not self.active or not self.is_available_by_time(now_dt):
|
||||
return False
|
||||
return True
|
||||
|
||||
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
|
||||
now_dt = now_dt or time_machine_now()
|
||||
subevent_var = subevent and subevent.var_overrides.get(self.pk)
|
||||
if not self.active:
|
||||
return 'active'
|
||||
elif self.available_from and self.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif self.available_until and self.available_until < now_dt:
|
||||
return 'available_until'
|
||||
elif subevent_var and subevent_var.available_from and subevent_var.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif subevent_var and subevent_var.available_until and subevent_var.available_until < now_dt:
|
||||
return 'available_until'
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def meta_data(self):
|
||||
data = self.item.meta_data
|
||||
|
||||
@@ -122,7 +122,6 @@ class ReusableMedium(LoggedModel):
|
||||
class Meta:
|
||||
unique_together = (("identifier", "type", "organizer"),)
|
||||
indexes = [
|
||||
models.Index(fields=("identifier", "type", "organizer")),
|
||||
models.Index(fields=("updated", "id")),
|
||||
]
|
||||
ordering = "identifier", "type", "organizer"
|
||||
|
||||
@@ -23,7 +23,6 @@ from django.db import models
|
||||
from django.db.models import Count, OuterRef, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.fields import I18nCharField
|
||||
@@ -31,6 +30,7 @@ from i18nfield.fields import I18nCharField
|
||||
from pretix.base.models import Customer
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.organizer import Organizer
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
from pretix.helpers.names import build_name
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ class MembershipType(LoggedModel):
|
||||
allow_parallel_usage = models.BooleanField(
|
||||
verbose_name=_('Parallel usage is allowed'),
|
||||
help_text=_('If this is selected, the membership can be used to purchase tickets for events happening at the same time. Note '
|
||||
'that this will only check for an identical start time of the events, not for any overlap between events.'),
|
||||
'that this will only check for an identical start time of the events, not for any overlap between events. An overlap '
|
||||
'check will be performed if there is a product-level validity of the ticket.'),
|
||||
default=False
|
||||
)
|
||||
max_usages = models.PositiveIntegerField(
|
||||
@@ -162,11 +163,15 @@ class Membership(models.Model):
|
||||
def attendee_name(self):
|
||||
return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme)
|
||||
|
||||
def is_valid(self, ev=None):
|
||||
if ev:
|
||||
def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False):
|
||||
if valid_from_not_chosen:
|
||||
return not self.canceled and self.date_end >= time_machine_now()
|
||||
elif ticket_valid_from:
|
||||
dt = ticket_valid_from
|
||||
elif ev:
|
||||
dt = ev.date_from
|
||||
else:
|
||||
dt = now()
|
||||
dt = time_machine_now()
|
||||
|
||||
return not self.canceled and dt >= self.date_start and dt <= self.date_end
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import operator
|
||||
@@ -44,7 +45,7 @@ from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from functools import reduce
|
||||
from time import sleep
|
||||
from typing import Any, Dict, List, Union
|
||||
from typing import Any, Dict, Iterable, List, Union
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import dateutil
|
||||
@@ -59,7 +60,7 @@ from django.db.models.functions import Coalesce, Greatest
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.crypto import get_random_string, salted_hmac
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
@@ -79,7 +80,8 @@ from pretix.base.i18n import language
|
||||
from pretix.base.models import Customer, User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import order_gracefully_delete
|
||||
from pretix.base.signals import allow_ticket_download, order_gracefully_delete
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
|
||||
from ...helpers import OF_SELF
|
||||
from ...helpers.countries import CachedCountries, FastCountryField
|
||||
@@ -104,6 +106,34 @@ def generate_position_secret():
|
||||
raise TypeError("Function no longer exists, use secret generators")
|
||||
|
||||
|
||||
class OrderQuerySet(models.QuerySet):
|
||||
def get_with_secret_check(self, code, received_secret, tag, secret_length=64):
|
||||
dummy = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"[:secret_length]
|
||||
try:
|
||||
order = self.get(code=code)
|
||||
except Order.DoesNotExist:
|
||||
# Do a hash comparison as well to harden against timing attacks
|
||||
hmac.compare_digest(
|
||||
salted_hmac(key_salt=b"", value=tag, algorithm="sha256",
|
||||
secret=dummy).hexdigest()[:secret_length],
|
||||
received_secret[:secret_length]
|
||||
)
|
||||
raise Order.DoesNotExist
|
||||
|
||||
if not hmac.compare_digest(
|
||||
order.tagged_secret(tag, secret_length) if tag else order.secret,
|
||||
received_secret[:secret_length].lower() if tag else received_secret.lower()
|
||||
) and not (
|
||||
# TODO: remove this clause after a while (compatibility with old secrets currently in flight)
|
||||
tag and hmac.compare_digest(
|
||||
hashlib.sha1(order.secret.lower().encode()).hexdigest(),
|
||||
received_secret.lower()
|
||||
)
|
||||
):
|
||||
raise Order.DoesNotExist
|
||||
return order
|
||||
|
||||
|
||||
class Order(LockModel, LoggedModel):
|
||||
"""
|
||||
An order is created when a user clicks 'buy' on his cart. It holds
|
||||
@@ -188,6 +218,14 @@ class Order(LockModel, LoggedModel):
|
||||
default=False,
|
||||
)
|
||||
testmode = models.BooleanField(default=False)
|
||||
organizer = models.ForeignKey(
|
||||
# Redundant foreign key, but is required for a uniqueness constraint
|
||||
"Organizer",
|
||||
related_name="orders",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
verbose_name=_("Event"),
|
||||
@@ -214,6 +252,7 @@ class Order(LockModel, LoggedModel):
|
||||
verbose_name=_('Locale')
|
||||
)
|
||||
secret = models.CharField(max_length=32, default=generate_secret)
|
||||
internal_secret = models.CharField(null=True, blank=True, max_length=32, default=generate_secret)
|
||||
datetime = models.DateTimeField(
|
||||
verbose_name=_("Date"), db_index=False
|
||||
)
|
||||
@@ -276,7 +315,7 @@ class Order(LockModel, LoggedModel):
|
||||
default=False,
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Order")
|
||||
@@ -286,6 +325,9 @@ class Order(LockModel, LoggedModel):
|
||||
models.Index(fields=["datetime", "id"]),
|
||||
models.Index(fields=["last_modified", "id"]),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.full_code
|
||||
@@ -451,9 +493,9 @@ class Order(LockModel, LoggedModel):
|
||||
if results:
|
||||
qs = qs.annotate(
|
||||
is_overpaid=Case(
|
||||
When(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=-1e-8),
|
||||
When(~Q(status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)) & Q(pending_sum_t__lt=-1e-8),
|
||||
then=Value(1)),
|
||||
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=-1e-8),
|
||||
When(Q(status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)) & Q(pending_sum_rc__lt=-1e-8),
|
||||
then=Value(1)),
|
||||
default=Value(0),
|
||||
output_field=models.IntegerField()
|
||||
@@ -468,7 +510,7 @@ class Order(LockModel, LoggedModel):
|
||||
is_underpaid=Case(
|
||||
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
|
||||
then=Value(1)),
|
||||
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__gt=1e-8),
|
||||
When(Q(status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)) & Q(pending_sum_rc__gt=1e-8),
|
||||
then=Value(1)),
|
||||
default=Value(0),
|
||||
output_field=models.IntegerField()
|
||||
@@ -499,6 +541,10 @@ class Order(LockModel, LoggedModel):
|
||||
self.set_expires()
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'expires'}.union(kwargs['update_fields'])
|
||||
if not self.organizer_id:
|
||||
self.organizer_id = self.event.organizer_id
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'organizer'}.union(kwargs['update_fields'])
|
||||
|
||||
is_new = not self.pk
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
@@ -666,7 +712,7 @@ class Order(LockModel, LoggedModel):
|
||||
for op in positions:
|
||||
if op.issued_gift_cards.all():
|
||||
return False
|
||||
if self.user_change_deadline and now() > self.user_change_deadline:
|
||||
if self.user_change_deadline and time_machine_now() > self.user_change_deadline:
|
||||
return False
|
||||
|
||||
return (
|
||||
@@ -698,7 +744,7 @@ class Order(LockModel, LoggedModel):
|
||||
return False
|
||||
if op.granted_memberships.with_usages().filter(usages__gt=0):
|
||||
return False
|
||||
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||
if self.user_cancel_deadline and time_machine_now() > self.user_cancel_deadline:
|
||||
return False
|
||||
|
||||
if self.status == Order.STATUS_PAID:
|
||||
@@ -835,8 +881,11 @@ class Order(LockModel, LoggedModel):
|
||||
if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED):
|
||||
return False
|
||||
|
||||
if self.event.settings.allow_modifications not in ("order", "attendee"):
|
||||
return False
|
||||
|
||||
modify_deadline = self.modify_deadline
|
||||
if modify_deadline is not None and now() > modify_deadline:
|
||||
if modify_deadline is not None and time_machine_now() > modify_deadline:
|
||||
return False
|
||||
|
||||
positions = list(
|
||||
@@ -888,7 +937,7 @@ class Order(LockModel, LoggedModel):
|
||||
return self.event.settings.ticket_download and (
|
||||
self.event.settings.ticket_download_date is None
|
||||
or self.ticket_download_date is None
|
||||
or now() > self.ticket_download_date
|
||||
or time_machine_now() > self.ticket_download_date
|
||||
) and (
|
||||
self.status == Order.STATUS_PAID
|
||||
or (
|
||||
@@ -960,7 +1009,7 @@ class Order(LockModel, LoggedModel):
|
||||
return error_messages['require_approval']
|
||||
term_last = self.payment_term_last
|
||||
if term_last and not ignore_date:
|
||||
if now() > term_last:
|
||||
if time_machine_now() > term_last:
|
||||
return error_messages['late_lastdate']
|
||||
|
||||
if self.status == self.STATUS_PENDING:
|
||||
@@ -983,7 +1032,7 @@ class Order(LockModel, LoggedModel):
|
||||
'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'),
|
||||
'voucher_usages': _('The voucher "{voucher}" has been used in the meantime.'),
|
||||
}
|
||||
now_dt = now_dt or now()
|
||||
now_dt = now_dt or time_machine_now()
|
||||
positions = list(self.positions.all().select_related('item', 'variation', 'seat', 'voucher'))
|
||||
quota_cache = {}
|
||||
v_budget = {}
|
||||
@@ -1090,9 +1139,6 @@ class Order(LockModel, LoggedModel):
|
||||
if not self.email and not (position and position.attendee_email):
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.locale, self.event.settings.region):
|
||||
recipient = self.email
|
||||
if position and position.attendee_email:
|
||||
@@ -1137,12 +1183,19 @@ class Order(LockModel, LoggedModel):
|
||||
attach_tickets=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def positions_with_tickets_ignoring_plugins(self):
|
||||
return (op for op in self.positions.select_related('item') if op.generate_ticket)
|
||||
|
||||
@property
|
||||
def positions_with_tickets(self):
|
||||
for op in self.positions.select_related('item'):
|
||||
if not op.generate_ticket:
|
||||
continue
|
||||
yield op
|
||||
signal_response = allow_ticket_download.send(self.event, order=self)
|
||||
if all([r is True for rr, r in signal_response]):
|
||||
return self.positions_with_tickets_ignoring_plugins
|
||||
elif any([r is False for rr, r in signal_response]):
|
||||
return []
|
||||
else:
|
||||
return set.intersection(set(self.positions_with_tickets_ignoring_plugins), *[set(r) for rr, r in signal_response if isinstance(r, Iterable)])
|
||||
|
||||
def create_transactions(self, is_new=False, positions=None, fees=None, dt_now=None, migrated=False,
|
||||
_backfill_before_cancellation=False, save=True):
|
||||
@@ -1200,6 +1253,10 @@ class Order(LockModel, LoggedModel):
|
||||
_transactions_mark_order_clean(self.pk)
|
||||
return create
|
||||
|
||||
def tagged_secret(self, tag, secret_length=64):
|
||||
return salted_hmac(value=tag, key_salt=b"", algorithm="sha256",
|
||||
secret=self.internal_secret or self.secret).hexdigest()[:secret_length]
|
||||
|
||||
|
||||
def answerfile_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
|
||||
@@ -2352,6 +2409,14 @@ class OrderPosition(AbstractPosition):
|
||||
"""
|
||||
positionid = models.PositiveIntegerField(default=1)
|
||||
|
||||
organizer = models.ForeignKey(
|
||||
# Redundant foreign key, but is required for a uniqueness constraint
|
||||
"Organizer",
|
||||
related_name="order_positions",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
verbose_name=_("Order"),
|
||||
@@ -2425,6 +2490,9 @@ class OrderPosition(AbstractPosition):
|
||||
verbose_name = _("Order position")
|
||||
verbose_name_plural = _("Order positions")
|
||||
ordering = ("positionid", "id")
|
||||
constraints = [
|
||||
models.UniqueConstraint("organizer", "secret", name="orderposition_organizer_secret_uniq")
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def sort_key(self):
|
||||
@@ -2483,6 +2551,43 @@ class OrderPosition(AbstractPosition):
|
||||
reasons[b] = b
|
||||
return reasons
|
||||
|
||||
@property
|
||||
def can_modify_answers(self) -> bool:
|
||||
"""
|
||||
``True`` if the user can change the question answers / attendee names that are
|
||||
related to the position. This checks order status and modification deadlines. It also
|
||||
returns ``False`` if there are no questions that can be answered.
|
||||
"""
|
||||
from .checkin import Checkin
|
||||
|
||||
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED):
|
||||
return False
|
||||
|
||||
if self.event.settings.allow_modifications != "attendee":
|
||||
return False
|
||||
|
||||
modify_deadline = self.order.modify_deadline
|
||||
if modify_deadline is not None and now() > modify_deadline:
|
||||
return False
|
||||
|
||||
positions = list(
|
||||
self.order.positions.all().annotate(
|
||||
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True))
|
||||
).select_related('item').prefetch_related('item__questions')
|
||||
)
|
||||
if not self.event.settings.allow_modifications_after_checkin:
|
||||
for cp in positions:
|
||||
if cp.has_checkin:
|
||||
return False
|
||||
|
||||
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
|
||||
for cp in positions:
|
||||
if cp.pk == self.pk or cp.addon_to_id == self.pk:
|
||||
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
|
||||
return True
|
||||
|
||||
return False # nothing there to modify
|
||||
|
||||
@classmethod
|
||||
def transform_cart_positions(cls, cp: List, order) -> list:
|
||||
from . import Voucher
|
||||
@@ -2494,7 +2599,8 @@ class OrderPosition(AbstractPosition):
|
||||
op = OrderPosition(order=order)
|
||||
for f in AbstractPosition._meta.fields:
|
||||
if f.name == 'addon_to':
|
||||
setattr(op, f.name, cp_mapping.get(cartpos.addon_to_id))
|
||||
if cartpos.addon_to_id:
|
||||
setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
|
||||
else:
|
||||
setattr(op, f.name, getattr(cartpos, f.name))
|
||||
op._calculate_tax()
|
||||
@@ -2504,9 +2610,9 @@ class OrderPosition(AbstractPosition):
|
||||
if cartpos.item.validity_mode:
|
||||
valid_from, valid_until = cartpos.item.compute_validity(
|
||||
requested_start=(
|
||||
max(cartpos.requested_valid_from, now())
|
||||
max(cartpos.requested_valid_from, time_machine_now())
|
||||
if cartpos.requested_valid_from and cartpos.item.validity_dynamic_start_choice
|
||||
else now()
|
||||
else time_machine_now()
|
||||
),
|
||||
enforce_start_limit=True,
|
||||
override_tz=order.event.timezone,
|
||||
@@ -2514,6 +2620,9 @@ class OrderPosition(AbstractPosition):
|
||||
op.valid_from = valid_from
|
||||
op.valid_until = valid_until
|
||||
|
||||
if op.is_bundled and not op.addon_to_id:
|
||||
raise ValueError("Bundled cart position without parent does not make sense.")
|
||||
|
||||
op.positionid = i + 1
|
||||
op.save()
|
||||
ops.append(op)
|
||||
@@ -2548,10 +2657,10 @@ class OrderPosition(AbstractPosition):
|
||||
self.item.id, self.variation.id if self.variation else 0, self.order_id
|
||||
)
|
||||
|
||||
def _calculate_tax(self, tax_rule=None):
|
||||
def _calculate_tax(self, tax_rule=None, invoice_address=None):
|
||||
self.tax_rule = tax_rule or self.item.tax_rule
|
||||
try:
|
||||
ia = self.order.invoice_address
|
||||
ia = invoice_address or self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
if self.tax_rule:
|
||||
@@ -2581,6 +2690,10 @@ class OrderPosition(AbstractPosition):
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'secret'}.union(kwargs['update_fields'])
|
||||
|
||||
if not self.organizer_id:
|
||||
self.organizer_id = self.order.event.organizer_id
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'organizer'}.union(kwargs['update_fields'])
|
||||
if not self.blocked and self.blocked is not None:
|
||||
self.blocked = None
|
||||
if 'update_fields' in kwargs:
|
||||
@@ -2650,9 +2763,6 @@ class OrderPosition(AbstractPosition):
|
||||
if not self.attendee_email:
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.order.locale, self.order.event.settings.region):
|
||||
recipient = self.attendee_email
|
||||
try:
|
||||
@@ -2931,6 +3041,14 @@ class CartPosition(AbstractPosition):
|
||||
self.item.id, self.variation.id if self.variation else 0, self.cart_id
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
# invalidate cached values of cached properties that likely have changed
|
||||
try:
|
||||
del self.sort_key
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def tax_value(self):
|
||||
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
|
||||
@@ -3020,9 +3138,9 @@ class CartPosition(AbstractPosition):
|
||||
def predicted_validity(self):
|
||||
return self.item.compute_validity(
|
||||
requested_start=(
|
||||
max(self.requested_valid_from, now())
|
||||
max(self.requested_valid_from, time_machine_now())
|
||||
if self.requested_valid_from and self.item.validity_dynamic_start_choice
|
||||
else now()
|
||||
else time_machine_now()
|
||||
),
|
||||
override_tz=self.event.timezone,
|
||||
)
|
||||
|
||||
@@ -263,6 +263,12 @@ class Team(LoggedModel):
|
||||
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members"))
|
||||
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
|
||||
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
|
||||
require_2fa = models.BooleanField(
|
||||
default=False, verbose_name=_("Require all members of this team to use two-factor authentication"),
|
||||
help_text=_("If you turn this on, all members of the team will be required to either set up two-factor "
|
||||
"authentication or leave the team. The setting may take a few minutes to become effective for "
|
||||
"all users.")
|
||||
)
|
||||
|
||||
can_create_events = models.BooleanField(
|
||||
default=False,
|
||||
|
||||
@@ -251,7 +251,8 @@ class Voucher(LoggedModel):
|
||||
null=True, blank=True,
|
||||
on_delete=models.PROTECT, # We use a fake version of SET_NULL in Item.delete()
|
||||
help_text=_(
|
||||
"This product is added to the user's cart if the voucher is redeemed."
|
||||
"This product is added to the user's cart if the voucher is redeemed. Instead of a specific product, you "
|
||||
"can also select a quota. In this case, all products assigned to this quota can be selected."
|
||||
)
|
||||
)
|
||||
variation = models.ForeignKey(
|
||||
@@ -350,9 +351,6 @@ class Voucher(LoggedModel):
|
||||
'variations.'))
|
||||
if variation and not item.variations.filter(pk=variation.pk).exists():
|
||||
raise ValidationError(_('This variation does not belong to this product.'))
|
||||
if item.has_variations and not variation and data.get('block_quota'):
|
||||
raise ValidationError(_('You can only block quota if you specify a specific product variation. '
|
||||
'Otherwise it might be unclear which quotas to block.'))
|
||||
if item.category and item.category.is_addon:
|
||||
raise ValidationError(_('It is currently not possible to create vouchers for add-on products.'))
|
||||
elif block_quota:
|
||||
@@ -372,10 +370,11 @@ class Voucher(LoggedModel):
|
||||
'redeemed': redeemed
|
||||
}
|
||||
)
|
||||
if data.get('max_usages', 1) < data.get('min_usages', 1):
|
||||
raise ValidationError(
|
||||
_('The maximum number of usages may not be lower than the minimum number of usages.'),
|
||||
)
|
||||
if data.get('min_usages') is not None:
|
||||
if data.get('max_usages', 1) < data.get('min_usages', 1):
|
||||
raise ValidationError(
|
||||
_('The maximum number of usages may not be lower than the minimum number of usages.'),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def clean_subevent(data, event):
|
||||
@@ -430,7 +429,15 @@ class Voucher(LoggedModel):
|
||||
elif old_instance.variation:
|
||||
quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
|
||||
elif old_instance.item:
|
||||
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
|
||||
if old_instance.item.has_variations:
|
||||
quotas |= set(
|
||||
Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
|
||||
itemvariation__item=old_instance.item,
|
||||
quota__subevent=old_instance.subevent,
|
||||
).values('quota_id'))
|
||||
)
|
||||
else:
|
||||
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
|
||||
return quotas
|
||||
|
||||
@staticmethod
|
||||
@@ -445,13 +452,19 @@ class Voucher(LoggedModel):
|
||||
|
||||
if quota:
|
||||
new_quotas = {quota}
|
||||
elif item and item.has_variations and not variation:
|
||||
raise ValidationError(_('You can only block quota if you specify a specific product variation. '
|
||||
'Otherwise it might be unclear which quotas to block.'))
|
||||
elif item and variation:
|
||||
new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
|
||||
elif item and not item.has_variations:
|
||||
new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
|
||||
elif item and item.has_variations:
|
||||
new_quotas = set(
|
||||
Quota.objects.filter(
|
||||
pk__in=Quota.variations.through.objects.filter(
|
||||
itemvariation__item=item,
|
||||
quota__subevent=data.get('subevent'),
|
||||
).values('quota_id')
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
|
||||
'tickets.'))
|
||||
@@ -505,9 +518,6 @@ class Voucher(LoggedModel):
|
||||
if item and seat.product != item:
|
||||
raise ValidationError(_('You need to choose the product "{prod}" for this seat.').format(prod=seat.product))
|
||||
|
||||
if not seat.is_available(ignore_voucher_id=pk):
|
||||
raise ValidationError(_('The seat "{id}" is already sold or currently blocked.').format(id=seat.seat_guid))
|
||||
|
||||
return seat
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -259,9 +259,6 @@ class WaitingListEntry(LoggedModel):
|
||||
if not self.email:
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.locale, self.event.settings.region):
|
||||
recipient = self.email
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.forms import PlaceholderValidator
|
||||
from pretix.base.forms import I18nMarkdownTextarea, PlaceholderValidator
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
|
||||
OrderRefund, Quota, TaxRule,
|
||||
@@ -67,6 +67,7 @@ from pretix.base.settings import SettingsSandbox
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.format import format_map
|
||||
@@ -849,7 +850,7 @@ class BasePaymentProvider:
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
if str(ia.country) not in restricted_countries:
|
||||
if str(ia.country) != '' and str(ia.country) not in restricted_countries:
|
||||
return False
|
||||
|
||||
if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']):
|
||||
@@ -1185,14 +1186,14 @@ class ManualPayment(BasePaymentProvider):
|
||||
label=_('Payment process description during checkout'),
|
||||
help_text=_('This text will be shown during checkout when the user selects this payment method. '
|
||||
'It should give a short explanation on this payment method.'),
|
||||
widget=I18nTextarea,
|
||||
widget=I18nMarkdownTextarea,
|
||||
)),
|
||||
('email_instructions', I18nFormField(
|
||||
label=_('Payment process description in order confirmation emails'),
|
||||
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
|
||||
'mails. It should instruct the user on how to proceed with the payment. You can use '
|
||||
'the placeholders {order}, {amount}, {currency} and {amount_with_currency}.'),
|
||||
widget=I18nTextarea,
|
||||
widget=I18nMarkdownTextarea,
|
||||
validators=[PlaceholderValidator(['{order}', '{amount}', '{currency}', '{amount_with_currency}'])],
|
||||
)),
|
||||
('pending_description', I18nFormField(
|
||||
@@ -1200,7 +1201,7 @@ class ManualPayment(BasePaymentProvider):
|
||||
help_text=_('This text will be shown on the order confirmation page for pending orders. '
|
||||
'It should instruct the user on how to proceed with the payment. You can use '
|
||||
'the placeholders {order}, {amount}, {currency} and {amount_with_currency}.'),
|
||||
widget=I18nTextarea,
|
||||
widget=I18nMarkdownTextarea,
|
||||
validators=[PlaceholderValidator(['{order}', '{amount}', '{currency}', '{amount_with_currency}'])],
|
||||
)),
|
||||
('invoice_immediately',
|
||||
@@ -1311,9 +1312,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
|
||||
@property
|
||||
def public_name(self) -> str:
|
||||
return str(self.settings.get("public_name", as_type=LazyI18nString)) or _(
|
||||
"Gift card"
|
||||
)
|
||||
return str(self.settings.get("public_name", as_type=LazyI18nString) or _("Gift card"))
|
||||
|
||||
@property
|
||||
def settings_form_fields(self):
|
||||
@@ -1327,7 +1326,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
(
|
||||
"public_description",
|
||||
I18nFormField(
|
||||
label=_("Payment method description"), widget=I18nTextarea, required=False
|
||||
label=_("Payment method description"), widget=I18nMarkdownTextarea, required=False
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1443,7 +1442,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
if not gc.testmode and self.event.testmode:
|
||||
messages.error(request, _("Only test gift cards can be used in test mode."))
|
||||
return
|
||||
if gc.expires and gc.expires < now():
|
||||
if gc.expires and gc.expires < time_machine_now():
|
||||
messages.error(request, _("This gift card is no longer valid."))
|
||||
return
|
||||
if gc.value <= Decimal("0.00"):
|
||||
@@ -1493,7 +1492,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
if not gc.testmode and payment.order.testmode:
|
||||
messages.error(request, _("Only test gift cards can be used in test mode."))
|
||||
return
|
||||
if gc.expires and gc.expires < now():
|
||||
if gc.expires and gc.expires < time_machine_now():
|
||||
messages.error(request, _("This gift card is no longer valid."))
|
||||
return
|
||||
if gc.value <= Decimal("0.00"):
|
||||
@@ -1541,7 +1540,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
raise PaymentException(_("This gift card can only be used in test mode."))
|
||||
if not gc.testmode and payment.order.testmode:
|
||||
raise PaymentException(_("Only test gift cards can be used in test mode."))
|
||||
if gc.expires and gc.expires < now():
|
||||
if gc.expires and gc.expires < time_machine_now():
|
||||
raise PaymentException(_("This gift card is no longer valid."))
|
||||
|
||||
trans = gc.transactions.create(
|
||||
|
||||
@@ -62,8 +62,7 @@ from django.utils.html import conditional_escape
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from pypdf import PdfReader, PdfWriter, Transformation
|
||||
from pypdf.generic import RectangleObject
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from reportlab.graphics import renderPDF
|
||||
from reportlab.graphics.barcode.qr import QrCodeWidget
|
||||
from reportlab.graphics.shapes import Drawing
|
||||
@@ -78,7 +77,7 @@ from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.platypus import Paragraph
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Order, OrderPosition, Question
|
||||
from pretix.base.models import Event, Order, OrderPosition, Question
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import layout_image_variables, layout_text_variables
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
@@ -408,6 +407,30 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"TIME_FORMAT"
|
||||
)
|
||||
}),
|
||||
("purchase_date", {
|
||||
"label": _("Purchase date"),
|
||||
"editor_sample": _("2017-05-31"),
|
||||
"evaluate": lambda op, order, ev: date_format(
|
||||
order.datetime.astimezone(ev.timezone),
|
||||
"SHORT_DATE_FORMAT"
|
||||
)
|
||||
}),
|
||||
("purchase_datetime", {
|
||||
"label": _("Purchase date and time"),
|
||||
"editor_sample": _("2017-05-31 19:00"),
|
||||
"evaluate": lambda op, order, ev: date_format(
|
||||
order.datetime.astimezone(ev.timezone),
|
||||
"SHORT_DATETIME_FORMAT"
|
||||
)
|
||||
}),
|
||||
("purchase_time", {
|
||||
"label": _("Purchase time"),
|
||||
"editor_sample": _("19:00"),
|
||||
"evaluate": lambda op, order, ev: date_format(
|
||||
order.datetime.astimezone(ev.timezone),
|
||||
"TIME_FORMAT"
|
||||
)
|
||||
}),
|
||||
("valid_from_date", {
|
||||
"label": _("Validity start date"),
|
||||
"editor_sample": _("2017-05-31"),
|
||||
@@ -738,9 +761,10 @@ class Renderer:
|
||||
else:
|
||||
self.bg_bytes = None
|
||||
self.bg_pdf = None
|
||||
self.event_fonts = list(get_fonts(event, pdf_support_required=True).keys()) + ['Open Sans']
|
||||
|
||||
@classmethod
|
||||
def _register_fonts(cls):
|
||||
def _register_fonts(cls, event: Event = None):
|
||||
if hasattr(cls, '_fonts_registered'):
|
||||
return
|
||||
pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
|
||||
@@ -748,7 +772,7 @@ class Renderer:
|
||||
pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')))
|
||||
|
||||
for family, styles in get_fonts().items():
|
||||
for family, styles in get_fonts(event, pdf_support_required=True).items():
|
||||
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
||||
if 'italic' in styles:
|
||||
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
|
||||
@@ -934,6 +958,13 @@ class Renderer:
|
||||
|
||||
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
||||
font = o['fontfamily']
|
||||
|
||||
# Since pdfmetrics.registerFont is global, we want to make sure that no one tries to sneak in a font, they
|
||||
# should not have access to.
|
||||
if font not in self.event_fonts:
|
||||
logger.warning(f'Unauthorized use of font "{font}"')
|
||||
font = 'Open Sans'
|
||||
|
||||
if o['bold']:
|
||||
font += ' B'
|
||||
if o['italic']:
|
||||
@@ -1061,32 +1092,11 @@ class Renderer:
|
||||
output = PdfWriter()
|
||||
|
||||
for i, page in enumerate(new_pdf.pages):
|
||||
bg_page = copy.deepcopy(self.bg_pdf.pages[i])
|
||||
bg_rotation = bg_page.get('/Rotate')
|
||||
if bg_rotation:
|
||||
# /Rotate is clockwise, transformation.rotate is counter-clockwise
|
||||
t = Transformation().rotate(bg_rotation)
|
||||
w = float(page.mediabox.getWidth())
|
||||
h = float(page.mediabox.getHeight())
|
||||
if bg_rotation in (90, 270):
|
||||
# offset due to rotation base
|
||||
if bg_rotation == 90:
|
||||
t = t.translate(h, 0)
|
||||
else:
|
||||
t = t.translate(0, w)
|
||||
# rotate mediabox as well
|
||||
page.mediabox = RectangleObject((
|
||||
page.mediabox.left.as_numeric(),
|
||||
page.mediabox.bottom.as_numeric(),
|
||||
page.mediabox.top.as_numeric(),
|
||||
page.mediabox.right.as_numeric(),
|
||||
))
|
||||
page.trimbox = page.mediabox
|
||||
elif bg_rotation == 180:
|
||||
t = t.translate(w, h)
|
||||
page.add_transformation(t)
|
||||
bg_page.merge_page(page)
|
||||
output.add_page(bg_page)
|
||||
bg_page = self.bg_pdf.pages[i]
|
||||
if bg_page.rotation != 0:
|
||||
bg_page.transfer_rotation_to_content()
|
||||
page.merge_page(bg_page, over=False)
|
||||
output.add_page(page)
|
||||
|
||||
output.add_metadata({
|
||||
'/Title': str(title),
|
||||
@@ -1119,32 +1129,11 @@ def merge_background(fg_pdf, bg_pdf, out_file, compress):
|
||||
else:
|
||||
output = PdfWriter()
|
||||
for i, page in enumerate(fg_pdf.pages):
|
||||
bg_page = copy.deepcopy(bg_pdf.pages[i])
|
||||
bg_rotation = bg_page.get('/Rotate')
|
||||
if bg_rotation:
|
||||
# /Rotate is clockwise, transformation.rotate is counter-clockwise
|
||||
t = Transformation().rotate(bg_rotation)
|
||||
w = float(page.mediabox.getWidth())
|
||||
h = float(page.mediabox.getHeight())
|
||||
if bg_rotation in (90, 270):
|
||||
# offset due to rotation base
|
||||
if bg_rotation == 90:
|
||||
t = t.translate(h, 0)
|
||||
else:
|
||||
t = t.translate(0, w)
|
||||
# rotate mediabox as well
|
||||
page.mediabox = RectangleObject((
|
||||
page.mediabox.left.as_numeric(),
|
||||
page.mediabox.bottom.as_numeric(),
|
||||
page.mediabox.top.as_numeric(),
|
||||
page.mediabox.right.as_numeric(),
|
||||
))
|
||||
page.trimbox = page.mediabox
|
||||
elif bg_rotation == 180:
|
||||
t = t.translate(w, h)
|
||||
page.add_transformation(t)
|
||||
bg_page.merge_page(page)
|
||||
output.add_page(bg_page)
|
||||
bg_page = bg_pdf.pages[i]
|
||||
if bg_page.rotation != 0:
|
||||
bg_page.transfer_rotation_to_content()
|
||||
page.merge_page(bg_page, over=False)
|
||||
output.add_page(page)
|
||||
output.write(out_file)
|
||||
|
||||
|
||||
|
||||
@@ -257,6 +257,8 @@ def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_us
|
||||
kwargs['valid_from'] = position.valid_from
|
||||
if 'valid_until' in params:
|
||||
kwargs['valid_until'] = position.valid_until
|
||||
if 'order_datetime' in params:
|
||||
kwargs['order_datetime'] = position.order.datetime
|
||||
secret = gen.generate_secret(
|
||||
item=position.item,
|
||||
variation=position.variation,
|
||||
|
||||
@@ -74,6 +74,7 @@ from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, LazyI18nStringList
|
||||
from pretix.base.signals import validate_cart_addons
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.base.timemachine import time_machine_now, time_machine_now_assigned
|
||||
from pretix.celery_app import app
|
||||
from pretix.presale.signals import (
|
||||
checkout_confirm_messages, fee_calculation_for_cart,
|
||||
@@ -113,6 +114,15 @@ error_messages = {
|
||||
'Some of the products you selected are no longer available in '
|
||||
'the quantity you selected. Please see below for details.'
|
||||
),
|
||||
'unavailable_listed': gettext_lazy(
|
||||
'Some of the products you selected are no longer available. '
|
||||
'The following products are affected and have not been added to your cart: %s'
|
||||
),
|
||||
'in_part_listed': gettext_lazy(
|
||||
'Some of the products you selected are no longer available in '
|
||||
'the quantity you selected. The following products are affected and have not '
|
||||
'been added to your cart: %s'
|
||||
),
|
||||
'max_items': ngettext_lazy(
|
||||
"You cannot select more than %s item per order.",
|
||||
"You cannot select more than %s items per order."
|
||||
@@ -194,7 +204,7 @@ error_messages = {
|
||||
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'min'
|
||||
),
|
||||
'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'addon_no_multi': gettext_lazy('You can select every add-on from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'addon_only': gettext_lazy('One of the products you selected can only be bought as an add-on to another product.'),
|
||||
'bundled_only': gettext_lazy('One of the products you selected can only be bought part of a bundle.'),
|
||||
'seat_required': gettext_lazy('You need to select a specific seat.'),
|
||||
@@ -269,7 +279,7 @@ class CartManager:
|
||||
sales_channel='web'):
|
||||
self.event = event
|
||||
self.cart_id = cart_id
|
||||
self.now_dt = now()
|
||||
self.real_now_dt = now()
|
||||
self._operations = []
|
||||
self._quota_diff = Counter()
|
||||
self._voucher_use_diff = Counter()
|
||||
@@ -296,10 +306,10 @@ class CartManager:
|
||||
return self._seated_cache[item, subevent]
|
||||
|
||||
def _calculate_expiry(self):
|
||||
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
|
||||
self._expiry = self.real_now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
|
||||
|
||||
def _check_presale_dates(self):
|
||||
if self.event.presale_start and self.now_dt < self.event.presale_start:
|
||||
if self.event.presale_start and time_machine_now(self.real_now_dt) < self.event.presale_start:
|
||||
raise CartError(error_messages['not_started'])
|
||||
if self.event.presale_has_ended:
|
||||
raise CartError(error_messages['ended'])
|
||||
@@ -310,13 +320,13 @@ class CartManager:
|
||||
tlv.datetime(self.event).date(),
|
||||
time(hour=23, minute=59, second=59)
|
||||
), self.event.timezone)
|
||||
if term_last < self.now_dt:
|
||||
if term_last < time_machine_now(self.real_now_dt):
|
||||
raise CartError(error_messages['payment_ended'])
|
||||
|
||||
def _extend_expiry_of_valid_existing_positions(self):
|
||||
# Extend this user's cart session to ensure all items in the cart expire at the same time
|
||||
# We can extend the reservation of items which are not yet expired without risk
|
||||
self.positions.filter(expires__gt=self.now_dt).update(expires=self._expiry)
|
||||
self.positions.filter(expires__gt=self.real_now_dt).update(expires=self._expiry)
|
||||
|
||||
def _delete_out_of_timeframe(self):
|
||||
err = None
|
||||
@@ -324,12 +334,12 @@ class CartManager:
|
||||
if not cp.pk:
|
||||
continue
|
||||
|
||||
if cp.subevent and cp.subevent.presale_start and self.now_dt < cp.subevent.presale_start:
|
||||
if cp.subevent and cp.subevent.presale_start and time_machine_now(self.real_now_dt) < cp.subevent.presale_start:
|
||||
err = error_messages['some_subevent_not_started']
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
|
||||
if cp.subevent and cp.subevent.presale_end and self.now_dt > cp.subevent.presale_end:
|
||||
if cp.subevent and cp.subevent.presale_end and time_machine_now(self.real_now_dt) > cp.subevent.presale_end:
|
||||
err = error_messages['some_subevent_ended']
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
@@ -341,7 +351,7 @@ class CartManager:
|
||||
tlv.datetime(cp.subevent).date(),
|
||||
time(hour=23, minute=59, second=59)
|
||||
), self.event.timezone)
|
||||
if term_last < self.now_dt:
|
||||
if term_last < time_machine_now(self.real_now_dt):
|
||||
err = error_messages['some_subevent_ended']
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
@@ -440,7 +450,7 @@ class CartManager:
|
||||
if op.subevent and not op.subevent.active:
|
||||
raise CartError(error_messages['inactive_subevent'])
|
||||
|
||||
if op.subevent and op.subevent.presale_start and self.now_dt < op.subevent.presale_start:
|
||||
if op.subevent and op.subevent.presale_start and time_machine_now(self.real_now_dt) < op.subevent.presale_start:
|
||||
raise CartError(error_messages['not_started'])
|
||||
|
||||
if op.subevent and op.subevent.presale_has_ended:
|
||||
@@ -463,7 +473,7 @@ class CartManager:
|
||||
tlv.datetime(op.subevent).date(),
|
||||
time(hour=23, minute=59, second=59)
|
||||
), self.event.timezone)
|
||||
if term_last < self.now_dt:
|
||||
if term_last < time_machine_now(self.real_now_dt):
|
||||
raise CartError(error_messages['payment_ended'])
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
@@ -500,7 +510,7 @@ class CartManager:
|
||||
)
|
||||
if not self.event.settings.seating_choice:
|
||||
requires_seat = Value(0, output_field=IntegerField())
|
||||
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
|
||||
expired = self.positions.filter(expires__lte=self.real_now_dt).select_related(
|
||||
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
|
||||
).annotate(
|
||||
requires_seat=requires_seat
|
||||
@@ -681,7 +691,7 @@ class CartManager:
|
||||
# than either of the possible default assumptions.
|
||||
predicted_redeemed_after = (
|
||||
voucher.redeemed +
|
||||
CartPosition.objects.filter(voucher=voucher, expires__gte=self.now_dt).count() +
|
||||
CartPosition.objects.filter(voucher=voucher, expires__gte=self.real_now_dt).count() +
|
||||
self._voucher_use_diff[voucher] +
|
||||
voucher_use_diff[voucher]
|
||||
)
|
||||
@@ -973,7 +983,7 @@ class CartManager:
|
||||
current_num = len(current_addons[cp].get(k, []))
|
||||
if input_num < current_num:
|
||||
for a in current_addons[cp][k][:current_num - input_num]:
|
||||
if a.expires > self.now_dt:
|
||||
if a.expires > self.real_now_dt:
|
||||
quotas = list(a.quotas)
|
||||
|
||||
for quota in quotas:
|
||||
@@ -987,7 +997,7 @@ class CartManager:
|
||||
|
||||
def _get_voucher_availability(self):
|
||||
vouchers_ok, self._voucher_depend_on_cart = _get_voucher_availability(
|
||||
self.event, self._voucher_use_diff, self.now_dt,
|
||||
self.event, self._voucher_use_diff, self.real_now_dt,
|
||||
exclude_position_ids=[
|
||||
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
|
||||
]
|
||||
@@ -1092,7 +1102,7 @@ class CartManager:
|
||||
shared_lock_objects=[self.event]
|
||||
)
|
||||
vouchers_ok = self._get_voucher_availability()
|
||||
quotas_ok = _get_quota_availability(self._quota_diff, self.now_dt)
|
||||
quotas_ok = _get_quota_availability(self._quota_diff, self.real_now_dt)
|
||||
err = None
|
||||
new_cart_positions = []
|
||||
deleted_positions = set()
|
||||
@@ -1105,9 +1115,11 @@ class CartManager:
|
||||
if 'sleep-after-quota-check' in debugflags_var.get():
|
||||
sleep(2)
|
||||
|
||||
err_unavailable_products = []
|
||||
|
||||
for iop, op in enumerate(self._operations):
|
||||
if isinstance(op, self.RemoveOperation):
|
||||
if op.position.expires > self.now_dt:
|
||||
if op.position.expires > self.real_now_dt:
|
||||
for q in op.position.quotas:
|
||||
quotas_ok[q] += 1
|
||||
addons = op.position.addons.all()
|
||||
@@ -1132,9 +1144,15 @@ class CartManager:
|
||||
voucher_available_count = min(voucher_available_count, vouchers_ok[op.voucher])
|
||||
|
||||
if quota_available_count < 1:
|
||||
err = err or error_messages['unavailable']
|
||||
err = err or error_messages['unavailable_listed']
|
||||
err_unavailable_products.append(
|
||||
f'{op.item.name} – {op.variation}' if op.variation else op.item.name
|
||||
)
|
||||
elif quota_available_count < requested_count:
|
||||
err = err or error_messages['in_part']
|
||||
err = err or error_messages['in_part_listed']
|
||||
err_unavailable_products.append(
|
||||
f'{op.item.name} – {op.variation}' if op.variation else op.item.name
|
||||
)
|
||||
|
||||
if voucher_available_count < 1:
|
||||
if op.voucher in self._voucher_depend_on_cart:
|
||||
@@ -1151,16 +1169,25 @@ class CartManager:
|
||||
b_quotas = list(b.quotas)
|
||||
if not b_quotas:
|
||||
if not op.voucher or not op.voucher.allow_ignore_quota:
|
||||
err = err or error_messages['unavailable']
|
||||
err = err or error_messages['unavailable_listed']
|
||||
err_unavailable_products.append(
|
||||
f'{op.item.name} – {op.variation}' if op.variation else op.item.name
|
||||
)
|
||||
available_count = 0
|
||||
continue
|
||||
b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b_quotas))
|
||||
if b_quota_available_count < b.count:
|
||||
err = err or error_messages['unavailable']
|
||||
err = err or error_messages['unavailable_listed']
|
||||
err_unavailable_products.append(
|
||||
f'{op.item.name} – {op.variation}' if op.variation else op.item.name
|
||||
)
|
||||
available_count = 0
|
||||
elif b_quota_available_count < available_count * b.count:
|
||||
err = err or error_messages['in_part']
|
||||
err = err or error_messages['in_part_listed']
|
||||
available_count = b_quota_available_count // b.count
|
||||
err_unavailable_products.append(
|
||||
f'{op.item.name} – {op.variation}' if op.variation else op.item.name
|
||||
)
|
||||
for q in b_quotas:
|
||||
quotas_ok[q] -= available_count * b.count
|
||||
# TODO: is this correct?
|
||||
@@ -1325,10 +1352,14 @@ class CartManager:
|
||||
|
||||
if 'sleep-before-commit' in debugflags_var.get():
|
||||
sleep(2)
|
||||
|
||||
if err in (error_messages['unavailable_listed'], error_messages['in_part_listed']):
|
||||
err = err % ', '.join(str(p) for p in err_unavailable_products)
|
||||
|
||||
return err
|
||||
|
||||
def recompute_final_prices_and_taxes(self):
|
||||
positions = sorted(list(self.positions), key=lambda op: -(op.addon_to_id or 0))
|
||||
positions = sorted(list(self.positions), key=lambda cp: (-(cp.addon_to_id or 0), cp.pk))
|
||||
diff = Decimal('0.00')
|
||||
for cp in positions:
|
||||
if cp.listed_price is None:
|
||||
@@ -1365,7 +1396,7 @@ class CartManager:
|
||||
err = self.extend_expired_positions() or err
|
||||
err = err or self._check_min_per_voucher()
|
||||
|
||||
self.now_dt = now()
|
||||
self.real_now_dt = now()
|
||||
|
||||
self._extend_expiry_of_valid_existing_positions()
|
||||
err = self._perform_operations() or err
|
||||
@@ -1457,7 +1488,7 @@ def get_fees(event, request, total, invoice_address, payments, positions):
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
|
||||
invoice_address: int=None, widget_data=None, sales_channel='web') -> None:
|
||||
invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
"""
|
||||
Adds a list of items to a user's cart.
|
||||
:param event: The event ID in question
|
||||
@@ -1465,7 +1496,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
:param cart_id: Session ID of a guest
|
||||
:raises CartError: On any error that occurred
|
||||
"""
|
||||
with language(locale):
|
||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
||||
ia = False
|
||||
if invoice_address:
|
||||
try:
|
||||
@@ -1487,14 +1518,14 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web') -> None:
|
||||
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param voucher: A voucher code
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||
@@ -1507,14 +1538,14 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web') -> None:
|
||||
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param position: A cart position ID
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||
@@ -1527,13 +1558,13 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web') -> None:
|
||||
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||
@@ -1547,14 +1578,14 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en',
|
||||
invoice_address: int=None, sales_channel='web') -> None:
|
||||
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param addons: A list of dicts with the keys addon_to, item, variation
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
||||
ia = False
|
||||
if invoice_address:
|
||||
try:
|
||||
|
||||
@@ -42,8 +42,8 @@ from dateutil.tz import datetime_exists
|
||||
from django.core.files import File
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import (
|
||||
BooleanField, Count, ExpressionWrapper, F, IntegerField, Max, Min,
|
||||
OuterRef, Q, Subquery, Value,
|
||||
BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min,
|
||||
OuterRef, Q, Subquery, TextField, Value, When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, TruncDate
|
||||
from django.dispatch import receiver
|
||||
@@ -273,6 +273,14 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
|
||||
var_texts[vname] = _('Only allowed before {datetime}').format(datetime=compare_to_text)
|
||||
elif operator == 'isAfter':
|
||||
var_texts[vname] = _('Only allowed after {datetime}').format(datetime=compare_to_text)
|
||||
elif var == 'entry_status':
|
||||
var_weights[vname] = (20, 0)
|
||||
if operator == '==' and rhs[0] == 'present':
|
||||
var_texts[vname] = _('Attendee is checked out')
|
||||
elif operator == '==' and rhs[0] == 'absent':
|
||||
var_texts[vname] = _('Attendee is already checked in')
|
||||
else:
|
||||
var_texts[vname] = f'{var} not {operator} {rhs}'
|
||||
elif var == 'product' or var == 'variation':
|
||||
var_weights[vname] = (1000, 0)
|
||||
var_texts[vname] = _('Ticket type not allowed')
|
||||
@@ -507,6 +515,13 @@ class LazyRuleVars:
|
||||
day=TruncDate('datetime', tzinfo=tz)
|
||||
).values('day').distinct().count()
|
||||
|
||||
@cached_property
|
||||
def entry_status(self):
|
||||
last_checkin = self._position.checkins.filter(list=self._clist).order_by('datetime').last()
|
||||
if not last_checkin or last_checkin.type == Checkin.TYPE_EXIT:
|
||||
return "absent"
|
||||
return "present"
|
||||
|
||||
@cached_property
|
||||
def minutes_since_last_entry(self):
|
||||
tz = self._clist.event.timezone
|
||||
@@ -569,6 +584,8 @@ class SQLLogic:
|
||||
'entries_days_since', 'entries_days_before'}
|
||||
|
||||
def operation_to_expression(self, rule):
|
||||
if isinstance(rule, str):
|
||||
return Value(rule)
|
||||
if not isinstance(rule, dict):
|
||||
return rule
|
||||
|
||||
@@ -770,6 +787,25 @@ class SQLLogic:
|
||||
Value(-1),
|
||||
output_field=IntegerField()
|
||||
)
|
||||
elif values[0] == 'entry_status':
|
||||
sq_last_checkin = Subquery(
|
||||
Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
list_id=self.list.pk,
|
||||
).order_by('-datetime').values('type')[:1]
|
||||
)
|
||||
|
||||
return Case(
|
||||
When(
|
||||
condition=Equal(
|
||||
sq_last_checkin,
|
||||
Value(Checkin.TYPE_ENTRY)
|
||||
),
|
||||
then=Value("present"),
|
||||
),
|
||||
default=Value("absent"),
|
||||
output_field=TextField()
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unknown operator {operator}')
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from pretix.base.models import CachedCombinedTicket, CachedTicket
|
||||
from pretix.base.models.customers import CustomerSSOGrant
|
||||
|
||||
from ..models import CachedFile, CartPosition, InvoiceAddress
|
||||
from ..models.auth import UserKnownLoginSource
|
||||
from ..signals import periodic_task
|
||||
|
||||
|
||||
@@ -75,3 +76,9 @@ def clearsessions(sender, **kwargs):
|
||||
@scopes_disabled()
|
||||
def clear_oidc_data(sender, **kwargs):
|
||||
CustomerSSOGrant.objects.filter(expires__lt=now() - timedelta(days=14)).delete()
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def clear_old_login_sources(sender, **kwargs):
|
||||
UserKnownLoginSource.objects.filter(last_seen__lt=now() - timedelta(days=365)).delete()
|
||||
|
||||
@@ -104,10 +104,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
|
||||
)
|
||||
|
||||
invoice.introductory_text = str(introductory).replace('\n', '<br />')
|
||||
invoice.additional_text = str(additional).replace('\n', '<br />')
|
||||
invoice.introductory_text = str(introductory).replace('\n', '<br />').replace('\r', '')
|
||||
invoice.additional_text = str(additional).replace('\n', '<br />').replace('\r', '')
|
||||
invoice.footer_text = str(footer)
|
||||
invoice.payment_provider_text = str(payment).replace('\n', '<br />')
|
||||
invoice.payment_provider_text = str(payment).replace('\n', '<br />').replace('\r', '')
|
||||
invoice.payment_provider_stamp = str(payment_stamp) if payment_stamp else None
|
||||
|
||||
try:
|
||||
@@ -462,10 +462,10 @@ def build_preview_invoice_pdf(event):
|
||||
footer = event.settings.get('invoice_footer_text', as_type=LazyI18nString)
|
||||
payment = _("A payment provider specific text might appear here.")
|
||||
|
||||
invoice.introductory_text = str(introductory).replace('\n', '<br />')
|
||||
invoice.additional_text = str(additional).replace('\n', '<br />')
|
||||
invoice.introductory_text = str(introductory).replace('\n', '<br />').replace('\r', '')
|
||||
invoice.additional_text = str(additional).replace('\n', '<br />').replace('\r', '')
|
||||
invoice.footer_text = str(footer)
|
||||
invoice.payment_provider_text = str(payment).replace('\n', '<br />')
|
||||
invoice.payment_provider_text = str(payment).replace('\n', '<br />').replace('\r', '')
|
||||
invoice.payment_provider_stamp = _('paid')
|
||||
invoice.invoice_to_name = _("John Doe")
|
||||
invoice.invoice_to_street = _("214th Example Street")
|
||||
@@ -488,7 +488,7 @@ def build_preview_invoice_pdf(event):
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice, description=_("Sample product {}").format(i + 1),
|
||||
gross_value=tax.gross, tax_value=tax.tax,
|
||||
tax_rate=tax.rate
|
||||
tax_rate=tax.rate, tax_name=tax.name
|
||||
)
|
||||
else:
|
||||
for i in range(5):
|
||||
|
||||
@@ -186,10 +186,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
headers.setdefault('X-Mailer', 'pretix')
|
||||
|
||||
with language(locale):
|
||||
if isinstance(context, dict) and event:
|
||||
for k, v in event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
if isinstance(context, dict) and order:
|
||||
try:
|
||||
context.update({
|
||||
@@ -387,6 +383,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
if event:
|
||||
with scopes_disabled():
|
||||
event = Event.objects.get(id=event)
|
||||
organizer = event.organizer
|
||||
backend = event.get_mail_backend()
|
||||
cm = lambda: scope(organizer=event.organizer) # noqa
|
||||
elif organizer:
|
||||
@@ -473,7 +470,8 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
# just "ABC-123.pdf", but we only do so if our currently selected language allows to do this
|
||||
# as ASCII text. For example, we would not want a "فاتورة_" prefix for our filename since this
|
||||
# has shown to cause deliverability problems of the email and deliverability wins.
|
||||
filename = pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf'
|
||||
with language(order.locale if order else inv.locale, event.settings.region if event else None):
|
||||
filename = pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf'
|
||||
if not re.match("^[a-zA-Z0-9-_%./,&:# ]+$", filename):
|
||||
filename = inv.number.replace(' ', '_') + '.pdf'
|
||||
filename = re.sub("[^a-zA-Z0-9-_.]+", "_", filename)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user