mirror of
https://github.com/pretix/pretix.git
synced 2025-12-07 22:42:26 +00:00
Compare commits
551 Commits
add-order-
...
release/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7429a16cd1 | ||
|
|
6035a11a24 | ||
|
|
40fbbdc6cf | ||
|
|
767c6feda6 | ||
|
|
60035ab7cf | ||
|
|
168af93825 | ||
|
|
dcf23aa0a0 | ||
|
|
36c823b14e | ||
|
|
8c905d22ea | ||
|
|
83692898ae | ||
|
|
5c7858c181 | ||
|
|
3aec460614 | ||
|
|
18159a1b77 | ||
|
|
216f931993 | ||
|
|
4587fed81b | ||
|
|
0642dcb7ba | ||
|
|
6267767ce7 | ||
|
|
3aa751aa10 | ||
|
|
053cfdf3a9 | ||
|
|
563583cd36 | ||
|
|
df1433786a | ||
|
|
7f6365cc81 | ||
|
|
56acb0929a | ||
|
|
a0831890ad | ||
|
|
da9aa3e133 | ||
|
|
91d99d5f14 | ||
|
|
83c6dd4d6b | ||
|
|
0ad13a4dbe | ||
|
|
3a2655e57d | ||
|
|
126fe34005 | ||
|
|
bd41c0e78e | ||
|
|
c49dec3835 | ||
|
|
66a7c7e6b8 | ||
|
|
50f048fa8b | ||
|
|
1035fa20e9 | ||
|
|
f4aaf2ad39 | ||
|
|
1dec993673 | ||
|
|
3c557e1ad6 | ||
|
|
3bb18f0440 | ||
|
|
c661d67f77 | ||
|
|
158d480bb3 | ||
|
|
759aa5a8ea | ||
|
|
5ea482456e | ||
|
|
e1a212156d | ||
|
|
9299ac5813 | ||
|
|
abfd8a7d86 | ||
|
|
4d75c568b5 | ||
|
|
abda800953 | ||
|
|
43d26451d1 | ||
|
|
2dd51b6f62 | ||
|
|
9d7d5389dc | ||
|
|
fefa15fedd | ||
|
|
2c905b0b39 | ||
|
|
1c4d64a3b5 | ||
|
|
b920795edf | ||
|
|
270d91080f | ||
|
|
3a26d6173e | ||
|
|
7ee630928b | ||
|
|
215a2f2dbb | ||
|
|
f1a29a7c5b | ||
|
|
e72f6746f2 | ||
|
|
8343b1256b | ||
|
|
d4c541b2bc | ||
|
|
43eff3b65f | ||
|
|
1c1beee5f6 | ||
|
|
dc3fc3cb45 | ||
|
|
f62a200f52 | ||
|
|
49d048fc49 | ||
|
|
fde1c67388 | ||
|
|
c8e818f786 | ||
|
|
5221ce965b | ||
|
|
196eb3482c | ||
|
|
1fe7fa91d5 | ||
|
|
d8ecb43e5d | ||
|
|
0cbb38f3d4 | ||
|
|
ab0e26047e | ||
|
|
079324863b | ||
|
|
ae445c1460 | ||
|
|
6593a64b18 | ||
|
|
b8c448fbd7 | ||
|
|
c1c828d8a2 | ||
|
|
1d0dbae77a | ||
|
|
c6cf8b6f24 | ||
|
|
78fbfc9c80 | ||
|
|
7ead117f2e | ||
|
|
a4417e97fd | ||
|
|
10e9b9e12d | ||
|
|
755c4efba1 | ||
|
|
a5910debf2 | ||
|
|
27e8be7e92 | ||
|
|
9f2627004b | ||
|
|
bba8fc051a | ||
|
|
12bef4ccdc | ||
|
|
e556b51651 | ||
|
|
2b966b28f2 | ||
|
|
fd7d37cfa3 | ||
|
|
ca79436e36 | ||
|
|
ca23073760 | ||
|
|
9c0acdf97f | ||
|
|
20edbe47d6 | ||
|
|
9b6e7f369c | ||
|
|
3ba36512d2 | ||
|
|
d7d419c826 | ||
|
|
acb35866ba | ||
|
|
11a04acae8 | ||
|
|
b882d84678 | ||
|
|
8e0ad694b7 | ||
|
|
39a0eaff3e | ||
|
|
7b3eeaf411 | ||
|
|
3f536c2c7a | ||
|
|
36203d653b | ||
|
|
fae45d066a | ||
|
|
5de5642b59 | ||
|
|
5991a71316 | ||
|
|
da09baa712 | ||
|
|
271d58d2d9 | ||
|
|
400967c9e2 | ||
|
|
b37947a94a | ||
|
|
f86e869702 | ||
|
|
43a27dc0b0 | ||
|
|
2e475432f6 | ||
|
|
03dfeab03f | ||
|
|
e86a53a763 | ||
|
|
99deb7a78c | ||
|
|
6c69bc6a78 | ||
|
|
9f6b9f03e6 | ||
|
|
d9b8d61ac6 | ||
|
|
6eff5d0cf8 | ||
|
|
d2a6c6600d | ||
|
|
1f01ab232e | ||
|
|
c74bd17f59 | ||
|
|
980d9a1671 | ||
|
|
0ac5290e90 | ||
|
|
c7a9ff52e3 | ||
|
|
111598db7a | ||
|
|
be53751fc7 | ||
|
|
8d07e2dc92 | ||
|
|
c2a42db9cd | ||
|
|
bedd841063 | ||
|
|
543b089acf | ||
|
|
575858aec6 | ||
|
|
6ceb5bd7ce | ||
|
|
162fd9e656 | ||
|
|
ae73370a3e | ||
|
|
5f6ec5e771 | ||
|
|
bf548904f3 | ||
|
|
f280eedc21 | ||
|
|
57b5036f36 | ||
|
|
545d911219 | ||
|
|
c58cfb59ad | ||
|
|
56803e3d65 | ||
|
|
7d8a788361 | ||
|
|
ecb4c34e08 | ||
|
|
75abab403a | ||
|
|
dea7de4e6c | ||
|
|
af3100a354 | ||
|
|
66ae5d1af2 | ||
|
|
9efb1653ee | ||
|
|
c6f1441976 | ||
|
|
7d08337c49 | ||
|
|
c92a1eb641 | ||
|
|
babe0934a8 | ||
|
|
160e0e0531 | ||
|
|
183387a683 | ||
|
|
cea0ac59bf | ||
|
|
3b6f116f5d | ||
|
|
e884c9820f | ||
|
|
7545e92373 | ||
|
|
b16680e0e5 | ||
|
|
eb04fdf4d2 | ||
|
|
c842ea597c | ||
|
|
b2b3fa36be | ||
|
|
af50bc2fa4 | ||
|
|
734a116b6f | ||
|
|
8f36e36619 | ||
|
|
2f35e1fcc6 | ||
|
|
cdfc2190d5 | ||
|
|
8dbc7ac5d7 | ||
|
|
b04ec720dc | ||
|
|
1478f191a8 | ||
|
|
171b1de0c4 | ||
|
|
799f1a5388 | ||
|
|
3cdb61fcf9 | ||
|
|
e5ce2deaeb | ||
|
|
f986f20f6a | ||
|
|
8f22d52e24 | ||
|
|
1c944baafa | ||
|
|
e55e3eeb0c | ||
|
|
063967528f | ||
|
|
edbe42c524 | ||
|
|
50a855a964 | ||
|
|
1ad81d21a4 | ||
|
|
0f8fa3fe47 | ||
|
|
45b9a705e2 | ||
|
|
3f07050d42 | ||
|
|
f97e6d0a71 | ||
|
|
c0031e4579 | ||
|
|
ce73d4831e | ||
|
|
fbfae0838a | ||
|
|
5445a79572 | ||
|
|
97a03f0aae | ||
|
|
780a5cdeee | ||
|
|
991f245dce | ||
|
|
8913d35838 | ||
|
|
7f2616cf80 | ||
|
|
7d9b54709a | ||
|
|
231e05f967 | ||
|
|
e50db7844b | ||
|
|
b43523ea65 | ||
|
|
447370d7b3 | ||
|
|
6a36efd18c | ||
|
|
c1ebbfe82a | ||
|
|
7f36a80dd8 | ||
|
|
c52b998ea8 | ||
|
|
eecd002ffd | ||
|
|
53027b621f | ||
|
|
826c54bce7 | ||
|
|
0cf2f06fcf | ||
|
|
12095c0c9c | ||
|
|
bca4cd1e23 | ||
|
|
53e84dfb08 | ||
|
|
2e8447486c | ||
|
|
5b184bb1a0 | ||
|
|
8c6f0a5dc1 | ||
|
|
6a53091b91 | ||
|
|
be4bc9a6f3 | ||
|
|
efb1141d59 | ||
|
|
322a730eb2 | ||
|
|
8d2224e725 | ||
|
|
5b819b76f0 | ||
|
|
5d90a42acf | ||
|
|
5398671fde | ||
|
|
f7d4460deb | ||
|
|
f76576a587 | ||
|
|
cf5f0dc7f9 | ||
|
|
567984bd5e | ||
|
|
1c6bd46d21 | ||
|
|
9ba3227837 | ||
|
|
21864885cb | ||
|
|
38173e3a54 | ||
|
|
4baf317934 | ||
|
|
c2b25bad06 | ||
|
|
9e3ad6c05c | ||
|
|
f017de1a21 | ||
|
|
b56bd8541e | ||
|
|
1c9219609a | ||
|
|
0c96f758a8 | ||
|
|
9bd3444aad | ||
|
|
10a83935d9 | ||
|
|
e8ea6e0f5c | ||
|
|
e94e5be878 | ||
|
|
1073ea626e | ||
|
|
23ab8df443 | ||
|
|
d6caf01a38 | ||
|
|
1424ae78e9 | ||
|
|
827382edc3 | ||
|
|
85482bc939 | ||
|
|
42ce545f2f | ||
|
|
e49bc5d78d | ||
|
|
6e7a32ef2a | ||
|
|
37df7a6313 | ||
|
|
d5951415a4 | ||
|
|
691159ed83 | ||
|
|
18f517af44 | ||
|
|
89ba2da7e7 | ||
|
|
c1c47e50c3 | ||
|
|
f262cd632c | ||
|
|
8d58294af1 | ||
|
|
ddc94a8a16 | ||
|
|
83811c0343 | ||
|
|
b2c05a72e5 | ||
|
|
8c56a23562 | ||
|
|
53e1d9c6c4 | ||
|
|
6250ab2165 | ||
|
|
6ada83df9a | ||
|
|
cfd6376936 | ||
|
|
edb0cd0941 | ||
|
|
88ac407cf3 | ||
|
|
5ba56fb5ac | ||
|
|
b51c9f7552 | ||
|
|
0853296663 | ||
|
|
721e7549bc | ||
|
|
aee86de330 | ||
|
|
756a4355d1 | ||
|
|
5119bbd0b1 | ||
|
|
728bd74e28 | ||
|
|
015ffeecbf | ||
|
|
0365f6d9fc | ||
|
|
e208a79c32 | ||
|
|
0037d37960 | ||
|
|
50d9b1e4a3 | ||
|
|
7919d012e6 | ||
|
|
327f95a9cc | ||
|
|
98946ded4b | ||
|
|
cf47b69bd3 | ||
|
|
fa5c69ce0a | ||
|
|
39d85fc112 | ||
|
|
23e222bf13 | ||
|
|
cb068b029f | ||
|
|
9e95f3be1b | ||
|
|
401c02865b | ||
|
|
062450002d | ||
|
|
6d834762c4 | ||
|
|
4f1e9a31c6 | ||
|
|
8ed3911dfb | ||
|
|
4562879cb2 | ||
|
|
ef0024b2ef | ||
|
|
8e603410fa | ||
|
|
16691ca2f6 | ||
|
|
d7e70fd0b9 | ||
|
|
071a3e2c9b | ||
|
|
1733c383b3 | ||
|
|
65ecdc184e | ||
|
|
63ae0724cf | ||
|
|
370d1bf06b | ||
|
|
06f361cece | ||
|
|
4b706339ed | ||
|
|
26213f2ba9 | ||
|
|
c183351d50 | ||
|
|
14131a7cec | ||
|
|
dfde308010 | ||
|
|
96b8631e09 | ||
|
|
84f464885d | ||
|
|
098147ce70 | ||
|
|
08b6186d77 | ||
|
|
e9e98a7821 | ||
|
|
3150c6a3ea | ||
|
|
898d1ab6ed | ||
|
|
52ae7626b0 | ||
|
|
c652911bfb | ||
|
|
52023cde09 | ||
|
|
b134f29cf6 | ||
|
|
19e1d132c2 | ||
|
|
393a218df5 | ||
|
|
f247eb0568 | ||
|
|
b35a388685 | ||
|
|
6dbbfe3b04 | ||
|
|
b2c49461bc | ||
|
|
23dcdf1fd1 | ||
|
|
1f80e9ef82 | ||
|
|
0969abb460 | ||
|
|
7b5789b110 | ||
|
|
f3b5996b82 | ||
|
|
5dcab59174 | ||
|
|
a2e38bb415 | ||
|
|
0510814aae | ||
|
|
dee2818f5d | ||
|
|
0d7809c36b | ||
|
|
4c494b5265 | ||
|
|
9e85e8c60a | ||
|
|
ab8c71fab8 | ||
|
|
1fa8ea3a12 | ||
|
|
f584d3d5af | ||
|
|
46ae911ade | ||
|
|
85db5698a6 | ||
|
|
09a17b57ce | ||
|
|
826962d6e2 | ||
|
|
f77e79bb38 | ||
|
|
d21e832204 | ||
|
|
119d4f0e04 | ||
|
|
feab6acfbd | ||
|
|
d85a6074ec | ||
|
|
6c813ea299 | ||
|
|
8a903f21ae | ||
|
|
a7f7c64cce | ||
|
|
82969daf37 | ||
|
|
8e9d0fb723 | ||
|
|
ef3d44e581 | ||
|
|
f9055fce9f | ||
|
|
cff0e86fd9 | ||
|
|
f0913fc720 | ||
|
|
23a9f60171 | ||
|
|
faf41c805c | ||
|
|
41cded095c | ||
|
|
90fb034897 | ||
|
|
f4203b7408 | ||
|
|
8a9f14db03 | ||
|
|
a2adf2825a | ||
|
|
8f7220b574 | ||
|
|
5adbdb80a8 | ||
|
|
3717c4b553 | ||
|
|
609f45d818 | ||
|
|
1d49c98cf2 | ||
|
|
586f42557f | ||
|
|
e3f219366d | ||
|
|
c571b269ff | ||
|
|
6d57501c5c | ||
|
|
5f3e039b2e | ||
|
|
8fa7aeef78 | ||
|
|
3b5baa7701 | ||
|
|
c6bb3e71bf | ||
|
|
104607d34e | ||
|
|
714ef0d3b6 | ||
|
|
db7c52ca93 | ||
|
|
fc94fbd9c8 | ||
|
|
61b3207ea2 | ||
|
|
ccf17db972 | ||
|
|
456bee7efa | ||
|
|
ccfdd364a3 | ||
|
|
cf92988eae | ||
|
|
6c561b1908 | ||
|
|
5634a16a85 | ||
|
|
6883ae268f | ||
|
|
f75f8dead6 | ||
|
|
0b28df8b83 | ||
|
|
0ffffc6a51 | ||
|
|
3f95f06845 | ||
|
|
22bb4a9ac4 | ||
|
|
ee50ee8e99 | ||
|
|
63a6b17229 | ||
|
|
f33153ef01 | ||
|
|
09517837ba | ||
|
|
0f9ec8beca | ||
|
|
6d604889f2 | ||
|
|
f9da500c06 | ||
|
|
8f3b92a5b4 | ||
|
|
c82aa891e6 | ||
|
|
591ff61d1b | ||
|
|
af3ba16631 | ||
|
|
dce0bba707 | ||
|
|
0a942a670f | ||
|
|
310b1f50bc | ||
|
|
0cef7029e1 | ||
|
|
fbc2a4cdc2 | ||
|
|
2daf6f6d97 | ||
|
|
1fe80fa8c5 | ||
|
|
fa0b31b19f | ||
|
|
3a77eeaa91 | ||
|
|
a1faa66ecd | ||
|
|
1e458d21f9 | ||
|
|
d1a051544f | ||
|
|
8bd4ddcd0d | ||
|
|
59a16789ea | ||
|
|
f4ce3654bb | ||
|
|
3ad99d8239 | ||
|
|
b415393ccf | ||
|
|
84dbd93d9e | ||
|
|
5a4f990ab9 | ||
|
|
35f3d95a46 | ||
|
|
c729b71320 | ||
|
|
8eb7c8db9e | ||
|
|
d5609f6ab0 | ||
|
|
5d8fa31bdf | ||
|
|
9360b1fd90 | ||
|
|
51da6570bf | ||
|
|
fbdbddd555 | ||
|
|
eb3edd83b8 | ||
|
|
25f5fe54a9 | ||
|
|
7bf153bb3b | ||
|
|
48e64071a1 | ||
|
|
95ea4fd4c9 | ||
|
|
206b57adfd | ||
|
|
b7f3f7a7a1 | ||
|
|
34e7a0fc31 | ||
|
|
cc7f249cb8 | ||
|
|
147061eaa4 | ||
|
|
c16491889b | ||
|
|
1eb1d8df5f | ||
|
|
3f47cf785c | ||
|
|
e8859cb2e2 | ||
|
|
61ab6f729d | ||
|
|
79c9ba3cf3 | ||
|
|
1d86f7a0c3 | ||
|
|
e259b3994a | ||
|
|
18e97624fd | ||
|
|
1c9a245231 | ||
|
|
b51ca58820 | ||
|
|
7a48cac862 | ||
|
|
1bdcc4580e | ||
|
|
dd10bdd433 | ||
|
|
f7a74c2e74 | ||
|
|
4037e1886d | ||
|
|
c4ae363fdb | ||
|
|
3df64a46e7 | ||
|
|
69502986ad | ||
|
|
51ea63335c | ||
|
|
dc76b554f8 | ||
|
|
f8be8296dd | ||
|
|
b3c917925c | ||
|
|
4954373a04 | ||
|
|
5571ec3858 | ||
|
|
9ef3139905 | ||
|
|
3139b9fe6f | ||
|
|
437d33ba79 | ||
|
|
0a9890b1b0 | ||
|
|
1420ad43db | ||
|
|
30da7a6429 | ||
|
|
a2f3dcce02 | ||
|
|
41f5ca3f9d | ||
|
|
817f1e0371 | ||
|
|
35fc001768 | ||
|
|
002416e435 | ||
|
|
4917249bab | ||
|
|
afd2468375 | ||
|
|
54d06dd7f8 | ||
|
|
5e59844cf5 | ||
|
|
0d2a981674 | ||
|
|
943aeaa31f | ||
|
|
cfe0f67f0d | ||
|
|
635bb94cc4 | ||
|
|
cf732ce173 | ||
|
|
74e9a4ad2d | ||
|
|
570357e9be | ||
|
|
473375d4ae | ||
|
|
a78b698520 | ||
|
|
332c968294 | ||
|
|
ad12c344c5 | ||
|
|
91c0db1ac0 | ||
|
|
4d231b70aa | ||
|
|
ab2f6f6bed | ||
|
|
28458f7b85 | ||
|
|
50ff968c17 | ||
|
|
0b4064f14f | ||
|
|
1897bd4b26 | ||
|
|
fd6843822b | ||
|
|
ee1644e037 | ||
|
|
a6c1486650 | ||
|
|
f4b437e92b | ||
|
|
446c55dc89 | ||
|
|
0990eeeea0 | ||
|
|
591fe23a99 | ||
|
|
ad70765287 | ||
|
|
c59d29493c | ||
|
|
bd32b33ba9 | ||
|
|
3a8556bb78 | ||
|
|
c972d24ce7 | ||
|
|
647e68ef01 | ||
|
|
f439a591df | ||
|
|
8f17b338d1 | ||
|
|
35350a13d6 | ||
|
|
0d93f7f52f | ||
|
|
170dcf93e7 | ||
|
|
9319202213 | ||
|
|
bfd0eee2c1 | ||
|
|
8570f53ed0 | ||
|
|
f56f6dd628 | ||
|
|
413fabd821 | ||
|
|
9813e59210 | ||
|
|
d91d942eac | ||
|
|
22104f79bd | ||
|
|
f289ad9e4f | ||
|
|
f81a734716 | ||
|
|
7a27a42e79 | ||
|
|
65a2bab9bb | ||
|
|
a26f46b619 | ||
|
|
5c37c85415 | ||
|
|
8ddba36690 | ||
|
|
f9bf05e09b | ||
|
|
8471422bba | ||
|
|
ee9acebe03 | ||
|
|
35d2a73f75 |
4
.github/workflows/style.yml
vendored
4
.github/workflows/style.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
||||
run: pip3 install -e ".[dev]" psycopg2-binary
|
||||
- name: Run isort
|
||||
run: isort -c .
|
||||
working-directory: ./src
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
||||
run: pip3 install -e ".[dev]" psycopg2-binary
|
||||
- name: Run flake8
|
||||
run: flake8 .
|
||||
working-directory: ./src
|
||||
|
||||
22
.github/workflows/tests.yml
vendored
22
.github/workflows/tests.yml
vendored
@@ -25,27 +25,17 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11"]
|
||||
database: [sqlite, postgres, mysql]
|
||||
database: [sqlite, postgres]
|
||||
exclude:
|
||||
- database: mysql
|
||||
python-version: "3.9"
|
||||
- database: mysql
|
||||
python-version: "3.11"
|
||||
- database: sqlite
|
||||
python-version: "3.9"
|
||||
- database: sqlite
|
||||
python-version: "3.10"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: getong/mariadb-action@v1.1
|
||||
with:
|
||||
mariadb version: '10.10'
|
||||
mysql database: 'pretix'
|
||||
mysql root password: ''
|
||||
if: matrix.database == 'mysql'
|
||||
- uses: harmon758/postgresql-action@v1
|
||||
with:
|
||||
postgresql version: '11'
|
||||
postgresql version: '15'
|
||||
postgresql db: 'pretix'
|
||||
postgresql user: 'postgres'
|
||||
postgresql password: 'postgres'
|
||||
@@ -61,9 +51,9 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install system dependencies
|
||||
run: sudo apt update && sudo apt install gettext mariadb-client
|
||||
run: sudo apt update && sudo apt install gettext
|
||||
- name: Install Python dependencies
|
||||
run: pip3 install --ignore-requires-python -e ".[dev]" mysqlclient psycopg2-binary # We ignore that flake8 needs newer python as we don't run flake8 during tests
|
||||
run: pip3 install --ignore-requires-python -e ".[dev]" psycopg2-binary # We ignore that flake8 needs newer python as we don't run flake8 during tests
|
||||
- name: Run checks
|
||||
run: python manage.py check
|
||||
working-directory: ./src
|
||||
@@ -76,6 +66,10 @@ jobs:
|
||||
- name: Run tests
|
||||
working-directory: ./src
|
||||
run: PRETIX_CONFIG_FILE=tests/travis_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml --reruns 3 tests --maxfail=100
|
||||
- name: Run concurrency tests
|
||||
working-directory: ./src
|
||||
run: PRETIX_CONFIG_FILE=tests/travis_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reruns 0 --reuse-db
|
||||
if: matrix.database == 'postgres'
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
|
||||
28
Dockerfile
28
Dockerfile
@@ -1,9 +1,8 @@
|
||||
FROM python:3.11-bullseye
|
||||
FROM python:3.11-bookworm
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libmariadb-dev \
|
||||
gettext \
|
||||
git \
|
||||
libffi-dev \
|
||||
@@ -21,21 +20,20 @@ RUN apt-get update && \
|
||||
supervisor \
|
||||
libmaxminddb0 \
|
||||
libmaxminddb-dev \
|
||||
zlib1g-dev && \
|
||||
zlib1g-dev \
|
||||
nodejs \
|
||||
npm && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
dpkg-reconfigure locales && \
|
||||
locale-gen C.UTF-8 && \
|
||||
/usr/sbin/update-locale LANG=C.UTF-8 && \
|
||||
dpkg-reconfigure locales && \
|
||||
locale-gen C.UTF-8 && \
|
||||
/usr/sbin/update-locale LANG=C.UTF-8 && \
|
||||
mkdir /etc/pretix && \
|
||||
mkdir /data && \
|
||||
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
|
||||
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
|
||||
mkdir /static && \
|
||||
mkdir /etc/supervisord && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
curl -qL https://www.npmjs.com/install.sh | sh
|
||||
mkdir /etc/supervisord
|
||||
|
||||
|
||||
ENV LC_ALL=C.UTF-8 \
|
||||
@@ -58,17 +56,17 @@ RUN pip3 install -U \
|
||||
wheel && \
|
||||
cd /pretix && \
|
||||
PRETIX_DOCKER_BUILD=TRUE pip3 install \
|
||||
-e ".[memcached,mysql]" \
|
||||
-e ".[memcached]" \
|
||||
gunicorn django-extensions ipython && \
|
||||
rm -rf ~/.cache/pip
|
||||
|
||||
RUN chmod +x /usr/local/bin/pretix && \
|
||||
rm /etc/nginx/sites-enabled/default && \
|
||||
cd /pretix/src && \
|
||||
rm -f pretix.cfg && \
|
||||
mkdir -p data && \
|
||||
chown -R pretixuser:pretixuser /pretix /data data && \
|
||||
sudo -u pretixuser make production
|
||||
rm -f pretix.cfg && \
|
||||
mkdir -p data && \
|
||||
chown -R pretixuser:pretixuser /pretix /data data && \
|
||||
sudo -u pretixuser make production
|
||||
|
||||
USER pretixuser
|
||||
VOLUME ["/etc/pretix", "/data"]
|
||||
|
||||
@@ -5,7 +5,7 @@ export DATA_DIR=/data/
|
||||
export HOME=/pretix
|
||||
|
||||
AUTOMIGRATE=${AUTOMIGRATE:-yes}
|
||||
NUM_WORKERS_DEFAULT=$((2 * $(nproc --all)))
|
||||
NUM_WORKERS_DEFAULT=$((2 * $(nproc)))
|
||||
export NUM_WORKERS=${NUM_WORKERS:-$NUM_WORKERS_DEFAULT}
|
||||
|
||||
if [ ! -d /data/logs ]; then
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pretix.settings import *
|
||||
|
||||
LOGGING['handlers']['mail_admins']['include_html'] = True
|
||||
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
|
||||
STORAGES["staticfiles"]["BACKEND"] = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
|
||||
|
||||
@@ -75,7 +75,7 @@ Example::
|
||||
The cookie domain to be set. Defaults to ``None``.
|
||||
|
||||
``registration``
|
||||
Enables or disables the registration of new admin users. Defaults to ``on``.
|
||||
Enables or disables the registration of new admin users. Defaults to ``off``.
|
||||
|
||||
``password_reset``
|
||||
Enables or disables password reset. Defaults to ``on``.
|
||||
@@ -152,24 +152,32 @@ Example::
|
||||
password=abcd
|
||||
host=localhost
|
||||
port=3306
|
||||
advisory_lock_index=1
|
||||
sslmode=require
|
||||
sslrootcert=/etc/pretix/postgresql-ca.crt
|
||||
sslcert=/etc/pretix/postgresql-client-crt.crt
|
||||
sslkey=/etc/pretix/postgresql-client-key.key
|
||||
|
||||
``backend``
|
||||
One of ``mysql`` (deprecated), ``sqlite3`` and ``postgresql``.
|
||||
One of ``sqlite3`` and ``postgresql``.
|
||||
Default: ``sqlite3``.
|
||||
|
||||
If you use MySQL, be sure to create your database using
|
||||
``CREATE DATABASE <dbname> CHARACTER SET utf8;``. Otherwise, Unicode
|
||||
support will not properly work.
|
||||
|
||||
``name``
|
||||
The database's name. Default: ``db.sqlite3``.
|
||||
|
||||
``user``, ``password``, ``host``, ``port``
|
||||
Connection details for the database connection. Empty by default.
|
||||
|
||||
``galera``
|
||||
(Deprecated) Indicates if the database backend is a MySQL/MariaDB Galera cluster and
|
||||
turns on some optimizations/special case handlers. Default: ``False``
|
||||
``advisory_lock_index``
|
||||
On PostgreSQL, pretix uses the "advisory lock" feature. However, advisory locks use a server-wide name space and
|
||||
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).
|
||||
|
||||
``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.
|
||||
|
||||
``sslcert``, ``sslkey``
|
||||
Connection mTLS details for the PostgreSQL database connection. It's also necessary to specify ``sslmode`` and ``sslrootcert`` parameters, please check the correct values from the TLS part. ``sslcert`` should be the accessible path of the client certificate. ``sslkey`` should be the accessible path of the client key. All values are empty by default.
|
||||
|
||||
.. _`config-replica`:
|
||||
|
||||
@@ -332,6 +340,10 @@ to speed up various operations::
|
||||
["sentinel_host_3", 26379]
|
||||
]
|
||||
password=password
|
||||
ssl_cert_reqs=required
|
||||
ssl_ca_certs=/etc/pretix/redis-ca.pem
|
||||
ssl_keyfile=/etc/pretix/redis-client-crt.pem
|
||||
ssl_certfile=/etc/pretix/redis-client-key.key
|
||||
|
||||
``location``
|
||||
The location of redis, as a URL of the form ``redis://[:password]@localhost:6379/0``
|
||||
@@ -355,6 +367,22 @@ to speed up various operations::
|
||||
If your redis setup doesn't require a password or you already specified it in the location you can omit this option.
|
||||
If this is set it will be passed to redis as the connection option PASSWORD.
|
||||
|
||||
``ssl_cert_reqs``
|
||||
If this is set it will be passed to redis as the connection option ``SSL_CERT_REQS``.
|
||||
Possible values are ``none``, ``optional``, and ``required``.
|
||||
|
||||
``ssl_ca_certs``
|
||||
If your redis setup doesn't require TLS you can omit this option.
|
||||
If this is set it will be passed to redis as the connection option ``SSL_CA_CERTS``. Possible value is the ca path.
|
||||
|
||||
``ssl_keyfile``
|
||||
If your redis setup doesn't require mTLS you can omit this option.
|
||||
If this is set it will be passed to redis as the connection option ``SSL_KEYFILE``. Possible value is the keyfile path.
|
||||
|
||||
``ssl_certfile``
|
||||
If your redis setup doesn't require mTLS you can omit this option.
|
||||
If this is set it will be passed to redis as the connection option ``SSL_CERTFILE``. Possible value is the certfile path.
|
||||
|
||||
If redis is not configured, pretix will store sessions and locks in the database. If memcached
|
||||
is configured, memcached will be used for caching instead of redis.
|
||||
|
||||
@@ -404,6 +432,8 @@ The two ``transport_options`` entries can be omitted in most cases.
|
||||
If they are present they need to be a valid JSON dictionary.
|
||||
For possible entries in that dictionary see the `Celery documentation`_.
|
||||
|
||||
It is possible the use Redis with TLS/mTLS for the broker or the backend. To do so, it is necessary to specify the TLS identifier ``rediss``, the ssl mode ``ssl_cert_reqs`` and optionally specify the CA (TLS) ``ssl_ca_certs``, cert ``ssl_certfile`` and key ``ssl_keyfile`` (mTLS) path as encoded string. the following uri describes the format and possible parameters ``rediss://0.0.0.0:6379/1?ssl_cert_reqs=required&ssl_ca_certs=%2Fetc%2Fpretix%2Fredis-ca.pem&ssl_certfile=%2Fetc%2Fpretix%2Fredis-client-crt.pem&ssl_keyfile=%2Fetc%2Fpretix%2Fredis-client-key.key``
|
||||
|
||||
To use redis with sentinels set the broker or backend to ``sentinel://sentinel_host_1:26379;sentinel_host_2:26379/0``
|
||||
and the respective transport_options to ``{"master_name":"mymaster"}``.
|
||||
If your redis instances behind the sentinel have a password use ``sentinel://:my_password@sentinel_host_1:26379;sentinel_host_2:26379/0``.
|
||||
|
||||
@@ -26,7 +26,7 @@ installation guides):
|
||||
* `Docker`_
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `PostgreSQL`_ 9.6+ database server
|
||||
* A `PostgreSQL`_ 12+ database server
|
||||
* A `redis`_ server
|
||||
|
||||
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
||||
@@ -321,11 +321,11 @@ workers, e.g. ``docker run … taskworker -Q notifications --concurrency 32``.
|
||||
|
||||
|
||||
.. _Docker: https://docs.docker.com/engine/installation/linux/debian/
|
||||
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
|
||||
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-22-04
|
||||
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
.. _pretix.eu: https://pretix.eu/
|
||||
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04
|
||||
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-22-04
|
||||
.. _redis: https://blog.programster.org/debian-8-install-redis-server/
|
||||
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall
|
||||
.. _redis website: https://redis.io/topics/security
|
||||
|
||||
@@ -68,7 +68,7 @@ generated key and installs the plugin from the URL we told you::
|
||||
mkdir -p /etc/ssh && \
|
||||
ssh-keyscan -t rsa -p 10022 code.rami.io >> /root/.ssh/known_hosts && \
|
||||
echo StrictHostKeyChecking=no >> /root/.ssh/config && \
|
||||
DJANGO_SETTINGS_MODULE=pretix.settings pip3 install -U "git+ssh://git@code.rami.io:10022/pretix/pretix-slack.git@stable#egg=pretix-slack" && \
|
||||
DJANGO_SETTINGS_MODULE= pip3 install -U "git+ssh://git@code.rami.io:10022/pretix/pretix-slack.git@stable#egg=pretix-slack" && \
|
||||
cd /pretix/src && \
|
||||
sudo -u pretixuser make production
|
||||
USER pretixuser
|
||||
|
||||
@@ -16,14 +16,11 @@ To use pretix, you will need the following things:
|
||||
* A periodic task runner, e.g. ``cron``
|
||||
|
||||
* **A database**. This needs to be a SQL-based that is supported by Django. We highly recommend to either
|
||||
go for **PostgreSQL** or **MySQL/MariaDB**. If you do not provide one, pretix will run on SQLite, which is useful
|
||||
go for **PostgreSQL**. If you do not provide one, pretix will run on SQLite, which is useful
|
||||
for evaluation and development purposes.
|
||||
|
||||
.. warning:: Do not ever use SQLite in production. It will break.
|
||||
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
* A **reverse proxy**. pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix
|
||||
is capable of doing this, having this handled by a proper web server like **nginx** or **Apache** will be much
|
||||
faster. Also, you need a proxying web server in front to provide SSL encryption.
|
||||
|
||||
@@ -12,7 +12,7 @@ solution with many things readily set-up, look at :ref:`dockersmallscale`.
|
||||
get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service
|
||||
offers at `pretix.eu`_.
|
||||
|
||||
We tested this guide on the Linux distribution **Debian 11.6** but it should work very similar on other
|
||||
We tested this guide on the Linux distribution **Debian 12** but it should work very similar on other
|
||||
modern distributions, especially on all systemd-based ones.
|
||||
|
||||
Requirements
|
||||
@@ -21,9 +21,10 @@ Requirements
|
||||
Please set up the following systems beforehand, we'll not explain them here in detail (but see these links for external
|
||||
installation guides):
|
||||
|
||||
* A python 3.9+ installation
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `PostgreSQL`_ 11+ database server
|
||||
* A `PostgreSQL`_ 12+ database server
|
||||
* A `redis`_ server
|
||||
* A `nodejs`_ installation
|
||||
|
||||
@@ -63,7 +64,7 @@ Package dependencies
|
||||
|
||||
To build and run pretix, you will need the following debian packages::
|
||||
|
||||
# apt-get install git build-essential python-dev python3-venv python3 python3-pip \
|
||||
# apt-get install git build-essential python3-dev python3-venv python3 python3-pip \
|
||||
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
||||
gettext libpq-dev libjpeg-dev libopenjp2-7-dev
|
||||
|
||||
@@ -129,9 +130,10 @@ We now install pretix, its direct dependencies and gunicorn::
|
||||
|
||||
Note that you need Python 3.9 or newer. You can find out your Python version using ``python -V``.
|
||||
|
||||
We also need to create a data directory::
|
||||
We also need to create a data directory and allow your webserver to traverse to the root directory::
|
||||
|
||||
(venv)$ mkdir -p /var/pretix/data/media
|
||||
(venv)$ chmod +x /var/pretix
|
||||
|
||||
Finally, we compile static files and translation data and create the database structure::
|
||||
|
||||
@@ -247,14 +249,14 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /var/pretix/venv/lib/python3.10/site-packages/pretix/static.dist/;
|
||||
alias /var/pretix/venv/lib/python3.11/site-packages/pretix/static.dist/;
|
||||
access_log off;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
}
|
||||
|
||||
.. note:: Remember to replace the ``python3.10`` in the ``/static/`` path in the config
|
||||
.. note:: Remember to replace the ``python3.11`` in the ``/static/`` path in the config
|
||||
above with your python version.
|
||||
|
||||
We recommend reading about setting `strong encryption settings`_ for your web server.
|
||||
@@ -323,11 +325,11 @@ Then, proceed like after any plugin installation::
|
||||
(venv)$ python -m pretix updatestyles
|
||||
# systemctl restart pretix-web pretix-worker
|
||||
|
||||
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
|
||||
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-22-04
|
||||
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
.. _pretix.eu: https://pretix.eu/
|
||||
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04
|
||||
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-22-04
|
||||
.. _redis: https://blog.programster.org/debian-8-install-redis-server/
|
||||
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall
|
||||
.. _strong encryption settings: https://mozilla.github.io/server-side-tls/ssl-config-generator/
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
Migrating from MySQL/MariaDB to PostgreSQL
|
||||
==========================================
|
||||
|
||||
Our recommended database for all production installations is PostgreSQL. Support for MySQL/MariaDB will be removed in
|
||||
pretix 5.0.
|
||||
Our recommended database for all production installations is PostgreSQL. Support for MySQL/MariaDB has been removed
|
||||
in newer pretix releases.
|
||||
|
||||
In order to follow this guide, your pretix installation needs to be a version that fully supports MySQL/MariaDB. If you
|
||||
already upgraded to pretix 5.0, downgrade back to the last 4.x release using ``pip``.
|
||||
already upgraded to pretix 5.0 or later, downgrade back to the last 4.x release using ``pip``.
|
||||
|
||||
.. note:: We have tested this guide carefully, but we can't assume any liability for its correctness. The data loss
|
||||
risk should be low as long as pretix is not running while you do the migration. If you are a pretix Enterprise
|
||||
@@ -16,12 +16,17 @@ already upgraded to pretix 5.0, downgrade back to the last 4.x release using ``p
|
||||
Update database schema
|
||||
----------------------
|
||||
|
||||
Before you start, make sure your database schema is up to date::
|
||||
Before you start, make sure your database schema is up to date. With a local installation::
|
||||
|
||||
# sudo -u pretix -s
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ python -m pretix migrate
|
||||
|
||||
With a docker installation::
|
||||
|
||||
docker exec -it pretix.service pretix migrate
|
||||
|
||||
|
||||
Install PostgreSQL
|
||||
------------------
|
||||
|
||||
@@ -70,10 +75,14 @@ Of course, instead of all this you can also run a PostgreSQL docker container an
|
||||
Stop pretix
|
||||
-----------
|
||||
|
||||
To prevent any more changes to your data, stop pretix from running::
|
||||
To prevent any more changes to your data, stop pretix from running. With a local installation::
|
||||
|
||||
# systemctl stop pretix-web pretix-worker
|
||||
|
||||
With docker::
|
||||
|
||||
# systemctl stop pretix
|
||||
|
||||
Change configuration
|
||||
--------------------
|
||||
|
||||
@@ -90,12 +99,16 @@ Change the database configuration in your ``/etc/pretix/pretix.cfg`` file::
|
||||
Create database schema
|
||||
-----------------------
|
||||
|
||||
To create the schema in your new PostgreSQL database, use the following commands::
|
||||
To create the schema in your new PostgreSQL database, use the following commands. With a local installation::
|
||||
|
||||
# sudo -u pretix -s
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ python -m pretix migrate
|
||||
|
||||
With docker::
|
||||
|
||||
# docker run --rm -v /var/pretix-data:/data -v /etc/pretix:/etc/pretix -v /var/run/redis:/var/run/redis pretix/standalone:stable migrate
|
||||
|
||||
|
||||
Migrate your data
|
||||
-----------------
|
||||
@@ -144,11 +157,18 @@ Afterwards, delete the file again::
|
||||
Start pretix
|
||||
------------
|
||||
|
||||
Now, restart pretix. Maybe stop your MySQL server as a verification step that you are no longer using it::
|
||||
Stop your MySQL server as a verification step that you are no longer using it::
|
||||
|
||||
# systemctl stop mariadb
|
||||
|
||||
Then, restart pretix. With a local installation::
|
||||
|
||||
# systemctl start pretix-web pretix-worker
|
||||
|
||||
With a docker installation::
|
||||
|
||||
# systemctl start pretix
|
||||
|
||||
And you're done! After you've verified everything has been copied correctly, you can delete the old MySQL database.
|
||||
|
||||
.. note:: Don't forget to update your backup process to back up your PostgreSQL database instead of your MySQL database now.
|
||||
|
||||
@@ -32,10 +32,16 @@ as well as the type of underlying hardware. Example:
|
||||
"token": "kpp4jn8g2ynzonp6",
|
||||
"hardware_brand": "Samsung",
|
||||
"hardware_model": "Galaxy S",
|
||||
"os_name": "Android",
|
||||
"os_version": "2.3.6",
|
||||
"software_brand": "pretixdroid",
|
||||
"software_version": "4.0.0"
|
||||
"software_version": "4.0.0",
|
||||
"rsa_pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh…nswIDAQAB\n-----END PUBLIC KEY-----\n"
|
||||
}
|
||||
|
||||
The ``rsa_pubkey`` is optional any only required for certain fatures such as working with reusable
|
||||
media and NFC cryptography.
|
||||
|
||||
Every initialization token can only be used once. On success, you will receive a response containing
|
||||
information on your device as well as your API token:
|
||||
|
||||
@@ -98,6 +104,8 @@ following endpoint:
|
||||
{
|
||||
"hardware_brand": "Samsung",
|
||||
"hardware_model": "Galaxy S",
|
||||
"os_name": "Android",
|
||||
"os_version": "2.3.6",
|
||||
"software_brand": "pretixdroid",
|
||||
"software_version": "4.1.0",
|
||||
"info": {"arbitrary": "data"}
|
||||
@@ -133,9 +141,29 @@ The response will look like this:
|
||||
"id": 3,
|
||||
"name": "South entrance"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"version": {
|
||||
"pretix": "3.6.0.dev0",
|
||||
"pretix_numeric": 30060001000
|
||||
}
|
||||
},
|
||||
"medium_key_sets": [
|
||||
{
|
||||
"public_id": 3456349,
|
||||
"organizer": "foo",
|
||||
"active": true,
|
||||
"media_type": "nfc_mf0aes",
|
||||
"uid_key": "base64-encoded-encrypted-key",
|
||||
"diversification_key": "base64-encoded-encrypted-key",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
``"medium_key_sets`` will always be empty if you did not set an ``rsa_pubkey``.
|
||||
The individual keys in the key sets are encrypted with the device's ``rsa_pubkey``
|
||||
using ``RSA/ECB/PKCS1Padding``.
|
||||
|
||||
Creating a new API key
|
||||
----------------------
|
||||
|
||||
|
||||
@@ -37,12 +37,18 @@ allow_entry_after_exit boolean If ``true``, su
|
||||
rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged.
|
||||
exit_all_at datetime Automatically check out (i.e. perform an exit scan) at this point in time. After this happened, this property will automatically be set exactly one day into the future. Note that this field is considered "internal configuration" and if you pull the list with ``If-Modified-Since``, the daily change in this field will not trigger a response.
|
||||
addon_match boolean If ``true``, tickets on this list can be redeemed by scanning their parent ticket if this still leads to an unambiguous match.
|
||||
ignore_in_statistics boolean If ``true``, check-ins on this list will be ignored in most reporting features.
|
||||
consider_tickets_used boolean If ``true`` (default), tickets checked in on this list will be considered "used" by other functionality, i.e. when checking if they can still be canceled.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 4.12
|
||||
|
||||
The ``addon_match`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2023.9
|
||||
|
||||
The ``ignore_in_statistics`` and ``consider_tickets_used`` attributes have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -767,4 +773,4 @@ Order position endpoints
|
||||
:statuscode 404: The requested order position or check-in list does not exist.
|
||||
|
||||
|
||||
.. _security issues: https://pretix.eu/about/de/blog/20220705-release-4111/
|
||||
.. _security issues: https://pretix.eu/about/de/blog/20220705-release-4111/
|
||||
|
||||
@@ -24,6 +24,8 @@ all_events boolean Whether this de
|
||||
limit_events list List of event slugs this device has access to
|
||||
hardware_brand string Device hardware manufacturer (read-only)
|
||||
hardware_model string Device hardware model (read-only)
|
||||
os_name string Device operating system name (read-only)
|
||||
os_version string Device operating system version (read-only)
|
||||
software_brand string Device software product (read-only)
|
||||
software_version string Device software version (read-only)
|
||||
created datetime Creation time
|
||||
@@ -76,6 +78,8 @@ Device endpoints
|
||||
"security_profile": "full",
|
||||
"hardware_brand": "Zebra",
|
||||
"hardware_model": "TC25",
|
||||
"os_name": "Android",
|
||||
"os_version": "8.1.0",
|
||||
"software_brand": "pretixSCAN",
|
||||
"software_version": "1.5.1"
|
||||
}
|
||||
@@ -123,6 +127,8 @@ Device endpoints
|
||||
"security_profile": "full",
|
||||
"hardware_brand": "Zebra",
|
||||
"hardware_model": "TC25",
|
||||
"os_name": "Android",
|
||||
"os_version": "8.1.0",
|
||||
"software_brand": "pretixSCAN",
|
||||
"software_version": "1.5.1"
|
||||
}
|
||||
@@ -173,6 +179,8 @@ Device endpoints
|
||||
"initialized": null
|
||||
"hardware_brand": null,
|
||||
"hardware_model": null,
|
||||
"os_name": null,
|
||||
"os_version": null,
|
||||
"software_brand": null,
|
||||
"software_version": null
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ subevent_mode strings Determines h
|
||||
``"same"`` (discount is only applied for groups within
|
||||
the same date), or ``"distinct"`` (discount is only applied
|
||||
for groups with no two same dates).
|
||||
condition_all_products boolean If ``true``, the discount applies to all items.
|
||||
condition_all_products boolean If ``true``, the discount condition applies to all items.
|
||||
condition_limit_products list of integers If ``condition_all_products`` is not set, this is a list
|
||||
of internal item IDs that the discount applies to.
|
||||
of internal item IDs that the discount condition applies to.
|
||||
condition_apply_to_addons boolean If ``true``, the discount applies to add-on products as well,
|
||||
otherwise it only applies to top-level items. The discount never
|
||||
applies to bundled products.
|
||||
@@ -48,6 +48,17 @@ benefit_discount_matching_percent decimal (string) The percenta
|
||||
benefit_only_apply_to_cheapest_n_matches integer If set higher than 0, the discount will only be applied to
|
||||
the cheapest matches. Useful for a "3 for 2"-style discount.
|
||||
Cannot be combined with ``condition_min_value``.
|
||||
benefit_same_products boolean If ``true``, the discount benefit applies to the same set of items
|
||||
as the condition (see above).
|
||||
benefit_limit_products list of integers If ``benefit_same_products`` is not set, this is a list
|
||||
of internal item IDs that the discount benefit applies to.
|
||||
benefit_apply_to_addons boolean (Only used if ``benefit_same_products`` is ``false``.)
|
||||
If ``true``, the discount applies to add-on products as well,
|
||||
otherwise it only applies to top-level items. The discount never
|
||||
applies to bundled products.
|
||||
benefit_ignore_voucher_discounted boolean (Only used if ``benefit_same_products`` is ``false``.)
|
||||
If ``true``, the discount does not apply to products which have
|
||||
been discounted by a voucher.
|
||||
======================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -94,6 +105,10 @@ Endpoints
|
||||
"condition_ignore_voucher_discounted": false,
|
||||
"condition_min_count": 3,
|
||||
"condition_min_value": "0.00",
|
||||
"benefit_same_products": true,
|
||||
"benefit_limit_products": [],
|
||||
"benefit_apply_to_addons": true,
|
||||
"benefit_ignore_voucher_discounted": false,
|
||||
"benefit_discount_matching_percent": "100.00",
|
||||
"benefit_only_apply_to_cheapest_n_matches": 1
|
||||
}
|
||||
@@ -146,6 +161,10 @@ Endpoints
|
||||
"condition_ignore_voucher_discounted": false,
|
||||
"condition_min_count": 3,
|
||||
"condition_min_value": "0.00",
|
||||
"benefit_same_products": true,
|
||||
"benefit_limit_products": [],
|
||||
"benefit_apply_to_addons": true,
|
||||
"benefit_ignore_voucher_discounted": false,
|
||||
"benefit_discount_matching_percent": "100.00",
|
||||
"benefit_only_apply_to_cheapest_n_matches": 1
|
||||
}
|
||||
@@ -184,6 +203,10 @@ Endpoints
|
||||
"condition_ignore_voucher_discounted": false,
|
||||
"condition_min_count": 3,
|
||||
"condition_min_value": "0.00",
|
||||
"benefit_same_products": true,
|
||||
"benefit_limit_products": [],
|
||||
"benefit_apply_to_addons": true,
|
||||
"benefit_ignore_voucher_discounted": false,
|
||||
"benefit_discount_matching_percent": "100.00",
|
||||
"benefit_only_apply_to_cheapest_n_matches": 1
|
||||
}
|
||||
@@ -211,6 +234,10 @@ Endpoints
|
||||
"condition_ignore_voucher_discounted": false,
|
||||
"condition_min_count": 3,
|
||||
"condition_min_value": "0.00",
|
||||
"benefit_same_products": true,
|
||||
"benefit_limit_products": [],
|
||||
"benefit_apply_to_addons": true,
|
||||
"benefit_ignore_voucher_discounted": false,
|
||||
"benefit_discount_matching_percent": "100.00",
|
||||
"benefit_only_apply_to_cheapest_n_matches": 1
|
||||
}
|
||||
@@ -267,6 +294,10 @@ Endpoints
|
||||
"condition_ignore_voucher_discounted": false,
|
||||
"condition_min_count": 3,
|
||||
"condition_min_value": "0.00",
|
||||
"benefit_same_products": true,
|
||||
"benefit_limit_products": [],
|
||||
"benefit_apply_to_addons": true,
|
||||
"benefit_ignore_voucher_discounted": false,
|
||||
"benefit_discount_matching_percent": "100.00",
|
||||
"benefit_only_apply_to_cheapest_n_matches": 1
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ Endpoints
|
||||
|
||||
The ``public_url`` field has been added.
|
||||
|
||||
.. versionchanged:: 5.0
|
||||
|
||||
The ``date_from_before``, ``date_from_after``, ``date_to_before``, and ``date_to_after`` query parameters have been
|
||||
added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/
|
||||
|
||||
Returns a list of all events within a given organizer the authenticated user/token has access to.
|
||||
@@ -141,6 +146,10 @@ Endpoints
|
||||
:query has_subevents: If set to ``true``/``false``, only events with a matching value of ``has_subevents`` are returned.
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned.
|
||||
:query date_from_after: If set to a date and time, only events that start at or after the given time are returned.
|
||||
:query date_from_before: If set to a date and time, only events that start at or before the given time are returned.
|
||||
:query date_to_after: If set to a date and time, only events that have an end date and end at or after the given time are returned.
|
||||
:query date_to_before: If set to a date and time, only events that have an end date and end at or before the given time are returned.
|
||||
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned.
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_from`` and
|
||||
``slug``. Keep in mind that ``date_from`` of event series does not really tell you anything.
|
||||
|
||||
@@ -111,7 +111,7 @@ Listing available exporters
|
||||
"input_parameters": [
|
||||
{
|
||||
"name": "events",
|
||||
"required": true
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "_format",
|
||||
|
||||
@@ -12,6 +12,7 @@ The invoice resource contains the following public fields:
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
number string Invoice number (with prefix)
|
||||
event string The slug of the parent event
|
||||
order string Order code of the order this invoice belongs to
|
||||
is_cancellation boolean ``true``, if this invoice is the cancellation of a
|
||||
different invoice.
|
||||
@@ -121,9 +122,13 @@ internal_reference string Customer's refe
|
||||
|
||||
The attribute ``lines.subevent`` has been added.
|
||||
|
||||
.. versionchanged:: 2023.8
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
The ``event`` attribute has been added. The organizer-level endpoint has been added.
|
||||
|
||||
|
||||
List of all invoices
|
||||
--------------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/
|
||||
|
||||
@@ -152,6 +157,7 @@ Endpoints
|
||||
"results": [
|
||||
{
|
||||
"number": "SAMPLECONF-00001",
|
||||
"event": "sampleconf",
|
||||
"order": "ABC12",
|
||||
"is_cancellation": false,
|
||||
"invoice_from_name": "Big Events LLC",
|
||||
@@ -221,6 +227,50 @@ Endpoints
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/invoices/
|
||||
|
||||
Returns a list of all invoices within all events of a given organizer (with sufficient access permissions).
|
||||
|
||||
Supported query parameters and output format of this endpoint are identical to the list endpoint within an event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"number": "SAMPLECONF-00001",
|
||||
"event": "sampleconf",
|
||||
"order": "ABC12",
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
|
||||
Fetching individual invoices
|
||||
----------------------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/
|
||||
|
||||
Returns information on one invoice, identified by its invoice number.
|
||||
@@ -243,6 +293,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"number": "SAMPLECONF-00001",
|
||||
"event": "sampleconf",
|
||||
"order": "ABC12",
|
||||
"is_cancellation": false,
|
||||
"invoice_from_name": "Big Events LLC",
|
||||
@@ -337,6 +388,12 @@ Endpoints
|
||||
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||
seconds.
|
||||
|
||||
|
||||
Modifying invoices
|
||||
------------------
|
||||
|
||||
Invoices cannot be edited directly, but the following actions can be triggered:
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/
|
||||
|
||||
Cancels the invoice and creates a new one.
|
||||
|
||||
@@ -20,6 +20,7 @@ The order resource contains the following public fields:
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
code string Order code
|
||||
event string The slug of the parent event
|
||||
status string Order status, one of:
|
||||
|
||||
* ``n`` – pending
|
||||
@@ -130,6 +131,14 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``valid_if_pending`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2023.8
|
||||
|
||||
The ``event`` attribute has been added. The organizer-level endpoint has been added.
|
||||
|
||||
.. versionchanged:: 2023.9
|
||||
|
||||
The ``customer`` query parameter has been added.
|
||||
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
@@ -289,6 +298,7 @@ List of all orders
|
||||
"results": [
|
||||
{
|
||||
"code": "ABC12",
|
||||
"event": "sampleconf",
|
||||
"status": "p",
|
||||
"testmode": false,
|
||||
"secret": "k24fiuwvu8kxz3y1",
|
||||
@@ -414,6 +424,7 @@ List of all orders
|
||||
:query string code: Only return orders that match the given order code
|
||||
:query string status: Only return orders in the given order status (see above)
|
||||
:query string search: Only return orders matching a given search query (matching for names, email addresses, and company names)
|
||||
:query string customer: Only show orders linked to the given customer.
|
||||
:query integer item: Only return orders with a position that contains this item ID. *Warning:* Result will also include orders if they contain mixed items, and it will even return orders where the item is only contained in a canceled position.
|
||||
:query integer variation: Only return orders with a position that contains this variation ID. *Warning:* Result will also include orders if they contain mixed items and variations, and it will even return orders where the variation is only contained in a canceled position.
|
||||
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
|
||||
@@ -441,6 +452,48 @@ List of all orders
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/orders/
|
||||
|
||||
Returns a list of all orders within all events of a given organizer (with sufficient access permissions).
|
||||
|
||||
Supported query parameters and output format of this endpoint are identical to the list endpoint within an event,
|
||||
with the exception that the ``pdf_data`` parameter is not supported here.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/orders/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
X-Page-Generated: 2017-12-01T10:00:00Z
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"code": "ABC12",
|
||||
"event": "sampleconf",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
Fetching individual orders
|
||||
--------------------------
|
||||
|
||||
@@ -466,6 +519,7 @@ Fetching individual orders
|
||||
|
||||
{
|
||||
"code": "ABC12",
|
||||
"event": "sampleconf",
|
||||
"status": "p",
|
||||
"testmode": false,
|
||||
"secret": "k24fiuwvu8kxz3y1",
|
||||
@@ -1517,6 +1571,7 @@ List of all order positions
|
||||
``order__datetime,positionid``
|
||||
:query string order: Only return positions of the order with the given order code
|
||||
:query string search: Fuzzy search matching the attendee name, order code, invoice address name as well as to the beginning of the secret.
|
||||
:query string customer: Only show orders linked to the given customer.
|
||||
:query integer item: Only return positions with the purchased item matching the given ID.
|
||||
:query integer item__in: Only return positions with the purchased item matching one of the given comma-separated IDs.
|
||||
:query integer variation: Only return positions with the purchased item variation matching the given ID.
|
||||
|
||||
@@ -18,7 +18,8 @@ The reusable medium resource contains the following public fields:
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the medium
|
||||
type string Type of medium, e.g. ``"barcode"`` or ``"nfc_uid"``.
|
||||
type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``.
|
||||
organizer string Organizer slug of the organizer who "owns" this medium.
|
||||
identifier string Unique identifier of the medium. The format depends on the ``type``.
|
||||
active boolean Whether this medium may be used.
|
||||
created datetime Date of creation
|
||||
@@ -36,6 +37,7 @@ Existing media types are:
|
||||
|
||||
- ``barcode``
|
||||
- ``nfc_uid``
|
||||
- ``nfc_mf0aes``
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -67,6 +69,7 @@ Endpoints
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"organizer": "bigevents",
|
||||
"identifier": "ABCDEFGH",
|
||||
"created": "2021-04-06T13:44:22.809377Z",
|
||||
"updated": "2021-04-06T13:44:22.809377Z",
|
||||
@@ -123,6 +126,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"organizer": "bigevents",
|
||||
"identifier": "ABCDEFGH",
|
||||
"created": "2021-04-06T13:44:22.809377Z",
|
||||
"updated": "2021-04-06T13:44:22.809377Z",
|
||||
@@ -152,6 +156,9 @@ Endpoints
|
||||
Look up a new reusable medium by its identifier. In some cases, this might lead to the automatic creation of a new
|
||||
medium behind the scenes.
|
||||
|
||||
This endpoint, and this endpoint only, might return media from a different organizer if there is a cross-acceptance
|
||||
agreement. In this case, only linked gift cards will be returned, no order position or customer records,
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -176,6 +183,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"organizer": "bigevents",
|
||||
"identifier": "ABCDEFGH",
|
||||
"created": "2021-04-06T13:44:22.809377Z",
|
||||
"updated": "2021-04-06T13:44:22.809377Z",
|
||||
@@ -235,6 +243,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"organizer": "bigevents",
|
||||
"identifier": "ABCDEFGH",
|
||||
"created": "2021-04-06T13:44:22.809377Z",
|
||||
"updated": "2021-04-06T13:44:22.809377Z",
|
||||
@@ -291,6 +300,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"organizer": "bigevents",
|
||||
"identifier": "ABCDEFGH",
|
||||
"created": "2021-04-06T13:44:22.809377Z",
|
||||
"updated": "2021-04-06T13:44:22.809377Z",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
Automated email rules
|
||||
Scheduled email rules
|
||||
=====================
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
Automated email rules that specify emails that the system will send automatically at a specific point in time, e.g.
|
||||
Scheduled email rules that specify emails that the system will send automatically at a specific point in time, e.g.
|
||||
the day of the event.
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
@@ -18,8 +18,19 @@ subject multi-lingual string The subject of
|
||||
template multi-lingual string The body of the email
|
||||
all_products boolean If ``true``, the email is sent to buyers of all products
|
||||
limit_products list of integers List of product IDs, if ``all_products`` is not set
|
||||
include_pending boolean If ``true``, the email is sent to pending orders. If ``false``,
|
||||
[**DEPRECATED**] include_pending boolean If ``true``, the email is sent to pending orders. If ``false``,
|
||||
only paid orders are considered.
|
||||
restrict_to_status list List of order states to restrict recipients to. Valid
|
||||
entries are ``p`` for paid, ``e`` for expired, ``c`` for canceled,
|
||||
``n__pending_approval`` for pending approval,
|
||||
``n__not_pending_approval_and_not_valid_if_pending`` for payment
|
||||
pending, ``n__valid_if_pending`` for payment pending but already confirmed,
|
||||
and ``n__pending_overdue`` for pending with payment overdue.
|
||||
The default is ``["p", "n__valid_if_pending"]``.
|
||||
checked_in_status string Check-in status to restrict recipients to. Valid strings are:
|
||||
``null`` for no filtering (default), ``checked_in`` for
|
||||
limiting to attendees that are or have been checked in, and
|
||||
``no_checkin`` for limiting to attendees who have not checked in.
|
||||
date_is_absolute boolean If ``true``, the email is set at a specific point in time.
|
||||
send_date datetime If ``date_is_absolute`` is set: Date and time to send the email.
|
||||
send_offset_days integer If ``date_is_absolute`` is not set, this is the number of days
|
||||
@@ -37,7 +48,10 @@ send_to string Can be ``"order
|
||||
or ``"both"``.
|
||||
date. Otherwise it is relative to the event start date.
|
||||
===================================== ========================== =======================================================
|
||||
.. versionchanged:: 2023.7
|
||||
|
||||
The ``include_pending`` field has been deprecated.
|
||||
The ``restrict_to_status`` field has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -74,7 +88,12 @@ Endpoints
|
||||
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"restrict_to_status": [
|
||||
"p",
|
||||
"n__not_pending_approval_and_not_valid_if_pending",
|
||||
"n__valid_if_pending"
|
||||
],
|
||||
"checked_in_status": null,
|
||||
"send_date": null,
|
||||
"send_offset_days": 1,
|
||||
"send_offset_time": "18:00",
|
||||
@@ -120,7 +139,12 @@ Endpoints
|
||||
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"restrict_to_status": [
|
||||
"p",
|
||||
"n__not_pending_approval_and_not_valid_if_pending",
|
||||
"n__valid_if_pending"
|
||||
],
|
||||
"checked_in_status": null,
|
||||
"send_date": null,
|
||||
"send_offset_days": 1,
|
||||
"send_offset_time": "18:00",
|
||||
@@ -157,7 +181,12 @@ Endpoints
|
||||
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"restrict_to_status": [
|
||||
"p",
|
||||
"n__not_pending_approval_and_not_valid_if_pending",
|
||||
"n__valid_if_pending"
|
||||
],
|
||||
"checked_in_status": "checked_in",
|
||||
"send_date": null,
|
||||
"send_offset_days": 1,
|
||||
"send_offset_time": "18:00",
|
||||
@@ -182,7 +211,12 @@ Endpoints
|
||||
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"restrict_to_status": [
|
||||
"p",
|
||||
"n__not_pending_approval_and_not_valid_if_pending",
|
||||
"n__valid_if_pending"
|
||||
],
|
||||
"checked_in_status": "checked_in",
|
||||
"send_date": null,
|
||||
"send_offset_days": 1,
|
||||
"send_offset_time": "18:00",
|
||||
@@ -235,7 +269,12 @@ Endpoints
|
||||
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"restrict_to_status": [
|
||||
"p",
|
||||
"n__not_pending_approval_and_not_valid_if_pending",
|
||||
"n__valid_if_pending"
|
||||
],
|
||||
"checked_in_status": "checked_in",
|
||||
"send_date": null,
|
||||
"send_offset_days": 1,
|
||||
"send_offset_time": "18:00",
|
||||
|
||||
@@ -63,6 +63,15 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``search`` query parameter has been added to filter sub-events by their name or location in any language.
|
||||
|
||||
.. versionchanged:: 5.0
|
||||
|
||||
The ``date_from_before``, ``date_from_after``, ``date_to_before``, and ``date_to_after`` query parameters have been
|
||||
added.
|
||||
|
||||
.. versionchanged:: 2023.8.0
|
||||
|
||||
For the organizer-wide endpoint, the ``search`` query parameter has been modified to filter sub-events by their parent events slug too.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -130,6 +139,10 @@ Endpoints
|
||||
:query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned.
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
|
||||
:query date_from_after: If set to a date and time, only events that start at or after the given time are returned.
|
||||
:query date_from_before: If set to a date and time, only events that start at or before the given time are returned.
|
||||
:query date_to_after: If set to a date and time, only events that have an end date and end at or after the given time are returned.
|
||||
:query date_to_before: If set to a date and time, only events that have an end date and end at or before the given time are returned.
|
||||
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
|
||||
:query search: Only return events matching a given search query.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
@@ -458,7 +471,12 @@ Endpoints
|
||||
:query event__live: If set to ``true``/``false``, only events with a matching value of ``live`` on the parent event are returned.
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
|
||||
:query date_from_after: If set to a date and time, only events that start at or after the given time are returned.
|
||||
:query date_from_before: If set to a date and time, only events that start at or before the given time are returned.
|
||||
:query date_to_after: If set to a date and time, only events that have an end date and end at or after the given time are returned.
|
||||
:query date_to_before: If set to a date and time, only events that have an end date and end at or before the given time are returned.
|
||||
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
|
||||
:query search: Only return events matching a given search query.
|
||||
:query sales_channel: If set to a sales channel identifier, the response will only contain subevents from events available on this sales channel.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
|
||||
@@ -20,11 +20,16 @@ internal_name string An optional nam
|
||||
rate decimal (string) Tax rate in percent
|
||||
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
|
||||
the specified product price
|
||||
eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
|
||||
eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied. Will
|
||||
be ignored if custom rules are set.
|
||||
home_country string Merchant country (required for reverse charge), can be
|
||||
``null`` or empty string
|
||||
keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom
|
||||
rules keep the gross price constant (default is ``false``)
|
||||
custom_rules object Dynamic rules specification. Each list element
|
||||
corresponds to one rule that will be processed in order.
|
||||
The current version of the schema in use can be found
|
||||
`here`_.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -32,6 +37,10 @@ keep_gross_if_rate_changes boolean If ``true``, ch
|
||||
|
||||
The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added.
|
||||
|
||||
.. versionchanged:: 2023.6
|
||||
|
||||
The ``custom_rules`` attribute has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -68,6 +77,7 @@ Endpoints
|
||||
"price_includes_tax": true,
|
||||
"eu_reverse_charge": false,
|
||||
"keep_gross_if_rate_changes": false,
|
||||
"custom_rules": null,
|
||||
"home_country": "DE"
|
||||
}
|
||||
]
|
||||
@@ -108,6 +118,7 @@ Endpoints
|
||||
"price_includes_tax": true,
|
||||
"eu_reverse_charge": false,
|
||||
"keep_gross_if_rate_changes": false,
|
||||
"custom_rules": null,
|
||||
"home_country": "DE"
|
||||
}
|
||||
|
||||
@@ -156,6 +167,7 @@ Endpoints
|
||||
"price_includes_tax": true,
|
||||
"eu_reverse_charge": false,
|
||||
"keep_gross_if_rate_changes": false,
|
||||
"custom_rules": null,
|
||||
"home_country": "DE"
|
||||
}
|
||||
|
||||
@@ -203,6 +215,7 @@ Endpoints
|
||||
"price_includes_tax": true,
|
||||
"eu_reverse_charge": false,
|
||||
"keep_gross_if_rate_changes": false,
|
||||
"custom_rules": null,
|
||||
"home_country": "DE"
|
||||
}
|
||||
|
||||
@@ -242,3 +255,5 @@ Endpoints
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this tax rule cannot be deleted since it is currently in use.
|
||||
|
||||
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/tax-rules-custom.schema.json
|
||||
|
||||
@@ -50,6 +50,10 @@ The following values for ``action_types`` are valid with pretix core:
|
||||
* ``pretix.event.order.payment.confirmed``
|
||||
* ``pretix.event.order.approved``
|
||||
* ``pretix.event.order.denied``
|
||||
* ``pretix.event.orders.waitinglist.added``
|
||||
* ``pretix.event.orders.waitinglist.changed``
|
||||
* ``pretix.event.orders.waitinglist.deleted``
|
||||
* ``pretix.event.orders.waitinglist.voucher_assigned``
|
||||
* ``pretix.event.checkin``
|
||||
* ``pretix.event.checkin.reverted``
|
||||
* ``pretix.event.added``
|
||||
@@ -63,6 +67,9 @@ The following values for ``action_types`` are valid with pretix core:
|
||||
* ``pretix.event.live.deactivated``
|
||||
* ``pretix.event.testmode.activated``
|
||||
* ``pretix.event.testmode.deactivated``
|
||||
* ``pretix.customer.created``
|
||||
* ``pretix.customer.changed``
|
||||
* ``pretix.customer.anonymized``
|
||||
|
||||
Installed plugins might register more valid values.
|
||||
|
||||
|
||||
@@ -18,13 +18,13 @@ If you want to add a custom view to the control area of an event, just register
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.conf.urls import url
|
||||
from django.urls import re_path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/mypluginname/',
|
||||
views.admin_view, name='backend'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/mypluginname/',
|
||||
views.admin_view, name='backend'),
|
||||
]
|
||||
|
||||
It is required that your URL parameters are called ``organizer`` and ``event``. If you want to
|
||||
|
||||
@@ -61,7 +61,7 @@ Backend
|
||||
item_formsets, order_search_filter_q, order_search_forms
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events, orderposition_blocked_display
|
||||
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events, orderposition_blocked_display, customer_created, customer_signed_in
|
||||
|
||||
Vouchers
|
||||
""""""""
|
||||
|
||||
@@ -70,6 +70,8 @@ The provider class
|
||||
|
||||
.. autoattribute:: settings_form_fields
|
||||
|
||||
.. autoattribute:: walletqueries
|
||||
|
||||
.. automethod:: settings_form_clean
|
||||
|
||||
.. automethod:: settings_content_render
|
||||
|
||||
@@ -37,7 +37,7 @@ you to execute a piece of code with a different locale:
|
||||
This is very useful e.g. when sending an email to a user that has a different language than the user performing the
|
||||
action that causes the mail to be sent.
|
||||
|
||||
.. _translation features: https://docs.djangoproject.com/en/1.9/topics/i18n/translation/
|
||||
.. _translation features: https://docs.djangoproject.com/en/4.2/topics/i18n/translation/
|
||||
.. _GNU gettext: https://www.gnu.org/software/gettext/
|
||||
.. _strings: https://django-i18nfield.readthedocs.io/en/latest/strings.html
|
||||
.. _database fields: https://django-i18nfield.readthedocs.io/en/latest/quickstart.html
|
||||
|
||||
@@ -18,3 +18,4 @@ Contents:
|
||||
email
|
||||
permissions
|
||||
logging
|
||||
locking
|
||||
|
||||
69
doc/development/implementation/locking.rst
Normal file
69
doc/development/implementation/locking.rst
Normal file
@@ -0,0 +1,69 @@
|
||||
.. highlight:: python
|
||||
|
||||
Resource locking
|
||||
================
|
||||
|
||||
.. versionchanged:: 2023.8
|
||||
|
||||
Our locking mechanism changed heavily in version 2023.8. Read `this PR`_ for background information.
|
||||
|
||||
One of pretix's core objectives as a ticketing system could be described as the management of scarce resources.
|
||||
Specifically, the following types of scarce-ness exist in pretix:
|
||||
|
||||
- Quotas can limit the number of tickets available
|
||||
- Seats can only be booked once
|
||||
- Vouchers can only be used a limited number of times
|
||||
- Some memberships can only be used a limited number of times
|
||||
|
||||
For all of these, it is critical that we prevent race conditions.
|
||||
While for some events it wouldn't be a big deal to sell a ticket more or less, for some it would be problematic and selling the same seat twice would always be catastrophic.
|
||||
|
||||
We therefore implement a standardized locking approach across the system to limit concurrency in cases where it could
|
||||
be problematic.
|
||||
|
||||
To acquire a lock on a set of quotas to create a new order that uses that quota, you should follow the following pattern::
|
||||
|
||||
with transaction.atomic(durable=True):
|
||||
quotas = Quota.objects.filter(...)
|
||||
lock_objects(quotas, shared_lock_objects=[event])
|
||||
check_quota(quotas)
|
||||
create_ticket()
|
||||
|
||||
The lock will automatically be released at the end of your database transaction.
|
||||
|
||||
Generally, follow the following guidelines during your development:
|
||||
|
||||
- **Always** acquire a lock on every **quota**, **voucher** or **seat** that you "use" during your transaction. "Use"
|
||||
here means any action after which the quota, voucher or seat will be **less available**, such as creating a cart
|
||||
position, creating an order, creating a blocking voucher, etc.
|
||||
|
||||
- There is **no need** to acquire a lock if you **free up** capacity, e.g. by canceling an order, deleting a voucher, etc.
|
||||
|
||||
- **Always** acquire a shared lock on the ``event`` you are working in whenever you acquire a lock on a quota, voucher,
|
||||
or seat.
|
||||
|
||||
- Only call ``lock_objects`` **once** per transaction. If you violate this rule, `deadlocks`_ become possible.
|
||||
|
||||
- For best performance, call ``lock_objects`` as **late** in your transaction as possible, but always before you check
|
||||
if the desired resource is still available in sufficient quantity.
|
||||
|
||||
Behind the scenes, the locking is implemented through `PostgreSQL advisory locks`_. You should also be aware of the following
|
||||
properties of our system:
|
||||
|
||||
- In some situations, an exclusive lock on the ``event`` is used, such as when the system can't determine for sure which
|
||||
seats will become unavailable after the transaction.
|
||||
|
||||
- An exclusive lock on the event is also used if you pass more than 20 objects to ``lock_objects``. This is a performance
|
||||
trade-off because it would take long to acquire all of the individual locks.
|
||||
|
||||
- If ``lock_objects`` is unable to acquire a lock within 3 seconds, a ``LockTimeoutException`` will be thrown.
|
||||
|
||||
.. note::
|
||||
|
||||
We currently do not use ``lock_objects`` for memberships. Instead, we use ``select_for_update()`` on the membership
|
||||
model. This might change in the future, but you should usually not be concerned about it since
|
||||
``validate_memberships_in_order(lock=True)`` will handle it for you.
|
||||
|
||||
.. _this PR: https://github.com/pretix/pretix/pull/2408
|
||||
.. _deadlocks: https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS
|
||||
.. _PostgreSQL advisory locks: https://www.postgresql.org/docs/11/explicit-locking.html#ADVISORY-LOCKS
|
||||
@@ -15,33 +15,41 @@ and the admin panel is available at ``https://pretix.eu/control/event/bigorg/awe
|
||||
|
||||
If the organizer now configures a custom domain like ``tickets.bigorg.com``, his event will
|
||||
from now on be available on ``https://tickets.bigorg.com/awesomecon/``. The former URL at
|
||||
``pretix.eu`` will redirect there. However, the admin panel will still only be available
|
||||
on ``pretix.eu`` for convenience and security reasons.
|
||||
``pretix.eu`` will redirect there. It's also possible to do this for just an event, in which
|
||||
case the event will be available on ``https://tickets.awesomecon.org/``.
|
||||
|
||||
However, the admin panel will still only be available on ``pretix.eu`` for convenience and security reasons.
|
||||
|
||||
URL routing
|
||||
-----------
|
||||
|
||||
The hard part about implementing this URL routing in Django is that
|
||||
``https://pretix.eu/bigorg/awesomecon/`` contains two parameters of nearly arbitrary content
|
||||
and ``https://tickets.bigorg.com/awesomecon/`` contains only one. The only robust way to do
|
||||
this is by having *separate* URL configuration for those two cases. In pretix, we call the
|
||||
former our ``maindomain`` config and the latter our ``subdomain`` config. For pretix's core
|
||||
modules we do some magic to avoid duplicate configuration, but for a fairly simple plugin with
|
||||
only a handful of routes, we recommend just configuring the two URL sets separately.
|
||||
and ``https://tickets.bigorg.com/awesomecon/`` contains only one and ``https://tickets.awesomecon.org/`` does not contain any.
|
||||
The only robust way to do this is by having *separate* URL configuration for those three cases.
|
||||
|
||||
In pretix, we therefore do not have a global URL configuration, but three, living in the following modules:
|
||||
|
||||
- ``pretix.multidomain.maindomain_urlconf``
|
||||
- ``pretix.multidomain.organizer_domain_urlconf``
|
||||
- ``pretix.multidomain.event_domain_urlconf``
|
||||
|
||||
We provide some helper utilities to work with these to avoid duplicate configuration of the individual URLs.
|
||||
The file ``urls.py`` inside your plugin package will be loaded and scanned for URL configuration
|
||||
automatically and should be provided by any plugin that provides any view.
|
||||
However, unlike plain Django, we look not only for a ``urlpatterns`` attribute on the module but support other
|
||||
attributes like ``event_patterns`` and ``organizer_patterns`` as well.
|
||||
|
||||
A very basic example that provides one view in the admin panel and one view in the frontend
|
||||
could look like this::
|
||||
For example, for a simple plugin that adds one URL to the backend and one event-level URL to the frontend, you can
|
||||
create the following configuration in your ``urls.py``::
|
||||
|
||||
from django.conf.urls import url
|
||||
from django.urls import re_path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/mypluginname/',
|
||||
views.AdminView.as_view(), name='backend'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/mypluginname/',
|
||||
views.AdminView.as_view(), name='backend'),
|
||||
]
|
||||
|
||||
event_patterns = [
|
||||
@@ -52,7 +60,7 @@ could look like this::
|
||||
As you can see, the view in the frontend is not included in the standard Django ``urlpatterns``
|
||||
setting but in a separate list with the name ``event_patterns``. This will automatically prepend
|
||||
the appropriate parameters to the regex (e.g. the event or the event and the organizer, depending
|
||||
on the called domain).
|
||||
on the called domain). For organizer-level views, ``organizer_patterns`` works the same way.
|
||||
|
||||
If you only provide URLs in the admin area, you do not need to provide a ``event_patterns`` attribute.
|
||||
|
||||
@@ -71,11 +79,16 @@ is a python method that emulates a behavior similar to ``reverse``:
|
||||
|
||||
.. autofunction:: pretix.multidomain.urlreverse.eventreverse
|
||||
|
||||
If you need to communicate the URL externally, you can use a different method to ensure that it is always an absolute URL:
|
||||
|
||||
.. autofunction:: pretix.multidomain.urlreverse.build_absolute_uri
|
||||
|
||||
In addition, there is a template tag that works similar to ``url`` but takes an event or organizer object
|
||||
as its first argument and can be used like this::
|
||||
|
||||
{% load eventurl %}
|
||||
<a href="{% eventurl request.event "presale:event.checkout" step="payment" %}">Pay</a>
|
||||
<a href="{% abseventurl request.event "presale:event.checkout" step="payment" %}">Pay</a>
|
||||
|
||||
|
||||
Implementation details
|
||||
|
||||
@@ -12,3 +12,4 @@ Developer documentation
|
||||
api/index
|
||||
structure
|
||||
translation/index
|
||||
nfc/index
|
||||
|
||||
15
doc/development/nfc/index.rst
Normal file
15
doc/development/nfc/index.rst
Normal file
@@ -0,0 +1,15 @@
|
||||
NFC media
|
||||
=========
|
||||
|
||||
pretix supports using NFC chips as "reusable media", for example to store gift cards or tickets.
|
||||
|
||||
Most of this implementation currently lives in our proprietary app pretixPOS, but in the future might also become part of our open-source pretixSCAN solution.
|
||||
Either way, we want this to be an open ecosystem and therefore document the exact mechanisms in use on the following pages.
|
||||
|
||||
We support multiple implementations of NFC media, each documented on its own page:
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
uid
|
||||
mf0aes
|
||||
113
doc/development/nfc/mf0aes.rst
Normal file
113
doc/development/nfc/mf0aes.rst
Normal file
@@ -0,0 +1,113 @@
|
||||
Mifare Ultralight AES
|
||||
=====================
|
||||
|
||||
We offer an implementation that provides a higher security level than the UID-based approach and uses the `Mifare Ultralight AES`_ chip sold by NXP.
|
||||
We believe the security model of this approach is adequate to the situation where this will usually be used and we'll outline known risks below.
|
||||
|
||||
If you want to dive deeper into the properties of the Mifare Ultralight AES chip, we recommend reading the `data sheet`_.
|
||||
|
||||
Random UIDs
|
||||
-----------
|
||||
|
||||
Mifare Ultralight AES supports a feature that returns a randomized UID every time a non-authenticated user tries to
|
||||
read the UID. This has a strong privacy benefit, since no unauthorized entity can use the NFC chips to track users.
|
||||
On the other hand, this reduces interoperability of the system. For example, this prevents you from using the same NFC
|
||||
chips for a different purpose where you only need the UID. This will also prevent your guests from reading their UID
|
||||
themselves with their phones, which might be useful e.g. in debugging situations.
|
||||
|
||||
Since there's no one-size-fits-all choice here, you can enable or disable this feature in the pretix organizer
|
||||
settings. If you change it, the change will apply to all newly encoded chips after the change.
|
||||
|
||||
Key management
|
||||
--------------
|
||||
|
||||
For every organizer, the server will generate create a "key set", which consists of a publicly known ID (random 32-bit integer) and two 16-byte keys ("diversification key" and "UID key").
|
||||
|
||||
Using our :ref:`Device authentication mechanism <rest-deviceauth>`, an authorized device can submit a locally generated RSA public key to the server.
|
||||
This key can no longer changed on the server once it is set, thus protecting against the attack scenario of a leaked device API token.
|
||||
|
||||
The server will then include key sets in the response to ``/api/v1/device/info``, encrypted with the device's RSA key.
|
||||
This includes all key sets generated for the organizer the device belongs to, as well as all keys of organizers that have granted sufficient access to this organizer.
|
||||
|
||||
The device will decrypt the key sets using its RSA key and store the key sets locally.
|
||||
|
||||
.. warning:: The device **will** have access to the raw key sets. Therefore, there is a risk of leaked master keys if an
|
||||
authorized device is stolen or abused. Our implementation in pretixPOS attempts to make this very hard on
|
||||
modern, non-rooted Android devices by keeping them encrypted with the RSA key and only storing the RSA key
|
||||
in the hardware-backed keystore of the device. A sufficiently motivated attacker, however, will likely still
|
||||
be able to extract the keys from a stolen device.
|
||||
|
||||
Encoding a chip
|
||||
---------------
|
||||
|
||||
When a new chip is encoded, the following steps will be taken:
|
||||
|
||||
- The UID of the chip is retrieved.
|
||||
|
||||
- A chip-specific key is generated using the mechanism documented in `AN10922`_ using the "diversification key" from the
|
||||
organizer's key set as the CMAC key and the diversification input concatenated in the from of ``0x01 + UID + APPID + SYSTEMID``
|
||||
with the following values:
|
||||
|
||||
- The UID of the chip as ``UID``
|
||||
|
||||
- ``"eu.pretix"`` (``0x65 0x75 0x2e 0x70 0x72 0x65 0x74 0x69 0x78``) as ``APPID``
|
||||
|
||||
- The ``public_id`` from the organizer's key set as a 4-byte big-endian value as ``SYSTEMID``
|
||||
|
||||
- The chip-specific key is written to the chip as the "data protection key" (config pages 0x30 to 0x33)
|
||||
|
||||
- The UID key from the organizer's key set is written to the chip as the "UID retrieval key" (config pages 0x34 to 0x37)
|
||||
|
||||
- The config page 0x29 is set like this:
|
||||
|
||||
- ``RID_ACT`` (random UID) to ``1`` or ``0`` based on the organizer's configuration
|
||||
- ``SEC_MSG_ACT`` (secure messaging) to ``1``
|
||||
- ``AUTH0`` (first page that needs authentication) to 0x04 (first non-UID page)
|
||||
|
||||
- The config page 0x2A is set like this:
|
||||
|
||||
- ``PROT`` to ``0`` (only write access restricted, not read access)
|
||||
- ``AUTHLIM`` to ``256`` (maximum number of wrong authentications before "self-desctruction")
|
||||
- Everything else to its default value (no lock bits are set)
|
||||
|
||||
- The ``public_id`` of the key set will be written to page 0x04 as a big-endian value
|
||||
|
||||
- The UID of the chip will be registered as a reusable medium on the server.
|
||||
|
||||
.. warning:: During encoding, the chip-specific key and the UID key are transmitted in plain text over the air. The
|
||||
security model therefore relies on the encoding of chips being performed in a trusted physical environment
|
||||
to prevent a nearby attacker from sniffing the keys with a strong antenna.
|
||||
|
||||
.. note:: If an attacker tries to authenticate with the chip 256 times using the wrong key, the chip will become
|
||||
unusable. A chip may also become unusable if it is detached from the reader in the middle of the encoding
|
||||
process (even though we've tried to implement it in a way that makes this unlikely).
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
When a chip is presented to the NFC reader, the following steps will be taken:
|
||||
|
||||
- Command ``GET_VERSION`` is used to determine if it is a Mifare Ultralight AES chip (if not, abort).
|
||||
|
||||
- Page 0x04 is read. If it is all zeroes, the chip is considered un-encoded (abort). If it contains a value that
|
||||
corresponds to the ``public_id`` of a known key set, this key set is used for all further operations. If it contains
|
||||
a different value, we consider this chip to belong to a different organizer or not to a pretix system at all (abort).
|
||||
|
||||
- An authentication with the chip using the UID key is performed.
|
||||
|
||||
- The UID of the chip will be read.
|
||||
|
||||
- The chip-specific key will be derived using the mechanism described above in the encoding step.
|
||||
|
||||
- An authentication with the chip using the chip-specific key is performed. If this is fully successful, this step
|
||||
proves that the chip knows the same chip-specific key as we do and is therefore an authentic chip encoded by us and
|
||||
we can trust its UID value.
|
||||
|
||||
- The UID is transmitted to the server to fetch the correct medium.
|
||||
|
||||
During these steps, the keys are never transmitted in plain text and can thus not be sniffed by a nearby attacker
|
||||
with a strong antenna.
|
||||
|
||||
.. _Mifare Ultralight AES: https://www.nxp.com/products/rfid-nfc/mifare-hf/mifare-ultralight/mifare-ultralight-aes-enhanced-security-for-limited-use-contactless-applications:MF0AESx20
|
||||
.. _data sheet: https://www.nxp.com/docs/en/data-sheet/MF0AES(H)20.pdf
|
||||
.. _AN10922: https://www.nxp.com/docs/en/application-note/AN10922.pdf
|
||||
10
doc/development/nfc/uid.rst
Normal file
10
doc/development/nfc/uid.rst
Normal file
@@ -0,0 +1,10 @@
|
||||
UID-based
|
||||
=========
|
||||
|
||||
With UID-based NFC, only the unique ID (UID) of the NFC chip is used for identification purposes.
|
||||
This can be used with virtually all NFC chips that provide compatibility with the NFC reader in use, typically at least all chips that comply with ISO/IEC 14443-3A.
|
||||
|
||||
We make only one restriction: The UID may not start with ``08``, since that usually signifies a randomized UID that changes on every read (which would not be very useful).
|
||||
|
||||
.. warning:: The UID-based approach provides only a very low level of security. It is easy to clone a chip with the same
|
||||
UID and impersonate someone else.
|
||||
@@ -96,6 +96,20 @@ http://localhost:8000/control/ for the admin view.
|
||||
port (for example because you develop on `pretixdroid`_), you can check
|
||||
`Django's documentation`_ for more options.
|
||||
|
||||
When running the local development webserver, ensure Celery is not configured
|
||||
in ``pretix.cfg``. i.e., you should remove anything such as::
|
||||
|
||||
[celery]
|
||||
backend=redis://redis:6379/2
|
||||
broker=redis://redis:6379/2
|
||||
|
||||
If you choose to use Celery for development, you must also start a Celery worker
|
||||
process::
|
||||
|
||||
celery -A pretix.celery_app worker -l info
|
||||
|
||||
However, beware that code changes will not auto-reload within Celery.
|
||||
|
||||
.. _`checksandtests`:
|
||||
|
||||
Code checks and unit tests
|
||||
|
||||
143
doc/plugins/epaybl.rst
Normal file
143
doc/plugins/epaybl.rst
Normal file
@@ -0,0 +1,143 @@
|
||||
ePayBL
|
||||
======
|
||||
|
||||
.. note::
|
||||
|
||||
Since ePayBL is only available to german federal, provincial and communal entities, the following page is also
|
||||
only provided in german. Should you require assistance with ePayBL and do not speak this language, please feel free
|
||||
reach out to support@pretix.eu.
|
||||
|
||||
|
||||
Einführung
|
||||
----------
|
||||
|
||||
.. note::
|
||||
|
||||
Sollten Sie lediglich schnell entscheiden wollen, welcher Kontierungsmodus in den Einstellungen des pretix
|
||||
ePayBL-plugins gewählt werden soll, so springen Sie direkt zur Sektion :ref:`Kontierungsmodus`.
|
||||
|
||||
|
||||
`ePayBL`_ - das ePayment-System von Bund und Länder - ist das am weitesten verbreitete Zahlungssystem für Bundes-, Länder-
|
||||
sowie kommunale Aufgabenträger. Während es nur wie eines von vielen anderen Zahlungssystemen scheint, so bietet es
|
||||
seinen Nutzern besondere Vorteile, wie die automatische Erfassung von Zahlungsbelegen, dem Übertragen von Buchungen in
|
||||
Haushaltskassen/-systeme sowie die automatische Erfassung von Kontierungen und Steuermerkmalen.
|
||||
|
||||
Rein technisch gesehen ist ePayBL hierbei nicht ein eigenständiger Zahlungsdienstleister sondern nur ein eine Komponente
|
||||
im komplexen System, dass die Zahlungsabwicklung für Kommunen und Behörden ist.
|
||||
|
||||
Im folgenden der schematische Aufbau einer Umgebung, in welcher ePayBL zum Einsatz kommt:
|
||||
|
||||
.. figure:: img/epaybl_flowchart.png
|
||||
:class: screenshot
|
||||
|
||||
Quelle: Integrationshandbuch ePayBL-Konnektor, DResearch Digital Media Systems GmbH
|
||||
|
||||
|
||||
In diesem Schaubild stellt pretix, bzw. die von Ihnen als Veranstalter angelegten Ticketshops, das Fachverfahren dar.
|
||||
|
||||
ePayBL stellt das Bindeglied zwischen den Fachverfahren, Haushaltssystemen und dem eigentlichen Zahlungsdienstleister,
|
||||
dem sog. ZV-Provider dar. Dieser ZV-Provider ist die Stelle, welche die eigentlichen Kundengelder einzieht und an den
|
||||
Händler auszahlt. Das Gros der Zahlungsdienstleister unterstützt pretix hierbei auch direkt; sprich: Sollten Sie die
|
||||
Anbindung an Ihre Haushaltssysteme nicht benötigen, kann eine direkte Anbindung in der Regel ebenso - und dies bei meist
|
||||
vermindertem Aufwand - vorgenommen werden.
|
||||
|
||||
In der Vergangenheit zeigte sich jedoch schnell, dass nicht jeder IT-Dienstleister immer sofort die neueste Version von
|
||||
ePayBL seinen Nutzern angeboten hat. Die Gründe hierfür sind mannigfaltig: Von fest vorgegebenen Update-Zyklen bis hin
|
||||
zu Systeme mit speziellen Anpassungen, kann leider nicht davon ausgegangen werden, dass alle ePayBL-Systeme exakt gleich
|
||||
ansprechbar sind - auch wenn es sich dabei eigentlich um einen standardisierten Dienst handelt.
|
||||
|
||||
Aus diesem Grund gibt es mit dem ePayBL-Konnektor eine weitere Abstraktionsschicht welche optional zwischen den
|
||||
Fachverfahren und dem ePayBL-Server sitzt. Dieser Konnektor wird so gepflegt, dass er zum einen eine dauerhaft
|
||||
gleichartige Schnittstelle den Fachverfahren bietet aber gleichzeitig auch mit jeder Version des ePayBL-Servers
|
||||
kommunizieren kann - egal wie neu oder alt, wie regulär oder angepasst diese ist.
|
||||
|
||||
Im Grunde müsste daher eigentlich immer gesagt werden, dass pretix eine Anbindung an den ePayBL-Konnektor bietet; nicht
|
||||
an "ePayBL" oder den "ePayBL-Server". Diese Unterscheidung kann bei der Ersteinrichtung und Anforderung von Zugangsdaten
|
||||
von Relevanz sein. Da in der Praxis jedoch beide Begriffe gleichbedeutend genutzt werden, wird im Folgenden auch nur von
|
||||
einer ePayBL-Anbindung die Rede sein - auch wenn explizit der Konnektor gemeint ist.
|
||||
|
||||
|
||||
.. _`Kontierungsmodus`:
|
||||
|
||||
Kontierungsmodus
|
||||
----------------
|
||||
|
||||
ePayBL ist ein Produkt, welches für die Abwicklung von Online-Zahlungsvorgängen in der Verwaltung geschaffen wurde. Ein
|
||||
Umfeld, in dem klar definiert ist, was ein Kunde gerade bezahlt und wohin das Geld genau fließt. Diese Annahmen lassen
|
||||
sich in einem Ticketshop wie pretix jedoch nur teilweise genauso abbilden.
|
||||
|
||||
Die ePayBL-Integration für pretix bietet daher zwei unterschiedliche Modi an, wie Buchungen erfasst und an ePayBL und
|
||||
damit auch an die dahinterliegenden Haushaltssysteme gemeldet werden können.
|
||||
|
||||
Kontierung pro Position/Artikel
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Dieser Modus versucht den klassischen, behördentypischen ePayBL-Zahlungsvorgang abzubilden: Jede einzelne Position, die
|
||||
ein Kunde in den Warenkorb legt, wird auch genauso 1:1 an ePayBL und die Hintergrundsysteme übermittelt.
|
||||
|
||||
Hierbei muss zwingend auch für jede Position ein Kennzeichen für Haushaltsstelle und Objektnummer, sowie optional ein
|
||||
Kontierungsobjekt (``HREF``; bspw. ``stsl=Steuerschlüssel;psp=gsb:Geschäftsbereich,auft:Innenauftrag,kst:Kostenstelle;``
|
||||
) übermittelt werden.
|
||||
|
||||
Diese Daten sind vom Veranstalter entsprechend für jeden in der Veranstaltung angelegten Artikel innerhalb des Tabs
|
||||
"Zusätzliche Einstellungen" der Produkteinstellungen zu hinterlegen.
|
||||
|
||||
Während diese Einstellung eine größtmögliche Menge an Kontierungsdaten überträgt und auch ein separates Verbuchen von
|
||||
Leistungen auf unterschiedliche Haushaltsstellen erlaubt, so hat diese Option auch einen großen Nachteil: Der Kunde kann
|
||||
nur eine Zahlung für seine Bestellung leisten.
|
||||
|
||||
Während sich dies nicht nach einem großen Problem anhört, so kann dies beim Kunden zu Frust führen. pretix bietet die
|
||||
Option an, dass ein Veranstalter eine Bestellung jederzeit verändern kann: Ändern von Preisen von Positionen in einer
|
||||
aufgegebenen Bestellung, Zubuchen und Entfernen von Bestellpositionen, etc. Hat der Kunde seine ursprüngliche Bestellung
|
||||
jedoch schon bezahlt, kann pretix nicht mehr die komplette Bestellung mit den passenden Kontierungen übertragen - es
|
||||
müsste nur ein Differenz-Abbild zwischen Ursprungsbestellung und aktueller Bestellung übertragen werden. Aber auch wenn
|
||||
eine "Nachmeldung" möglich wäre, so wäre ein konkretes Auflösen für was jetzt genau gezahlt wird, nicht mehr möglich.
|
||||
|
||||
Daher gilt bei der Nutzung der Kontierung pro Position/Artikel: Der Kunde kann nur eine (erfolgreiche) Zahlung auf seine
|
||||
Bestellung leisten.
|
||||
|
||||
Eine weitere Einschränkung dieses Modus ist, dass aktuell keine Gebühren-Positionen (Versandkosten, Zahlungs-, Storno-
|
||||
oder Servicegebühren) in diesem Modus übertragen werden können. Bitte wenden Sie sich an uns, wenn Sie diese
|
||||
Funktionalität benötigen.
|
||||
|
||||
|
||||
Kontierung pro Zahlvorgang
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Dieser Modus verabschiedet sich vom behördlichen "Jede Position gehört genau zu einem Haushaltskonto und muss genau
|
||||
zugeordnet werden". Stattdessen werden alle Bestellpositionen - inklusive eventuell definierter Gebühren - vermengt und
|
||||
nur als ein großer Warenkorb, genauer gesagt: eine einzige Position an ePayBL sowie die Hintergrundsysteme gemeldet.
|
||||
|
||||
Während im "pro Postion/Artikel"-Modus jeder Artikel einzeln übermittelt wird und damit auch korrekt pro Artikel der
|
||||
jeweilige Brutto- und Nettopreis, sowie der anfallende Steuerbetrag und ein Steuerkennzeichen (mit Hilfe des optionalen
|
||||
``HREF``-Attributs) übermittelt werden, ist dies im "pro Zahlvorgang"-Modus nicht möglich.
|
||||
|
||||
Stattdessen übermittelt pretix nur einen Betrag für den gesamten Warenkorb: Bruttopreis == Nettopreis. Der Steuerbetrag
|
||||
wird hierbei als 0 übermittelt.
|
||||
|
||||
Die Angabe einer Haushaltsstelle und Objektnummer, sowie optional der ``HREF``-Kontierungsinformationen ist jedoch
|
||||
weiterhin notwendig - allerdings nicht mehr individuell für jeden Artikel/jede Position sondern nur für die gesamte
|
||||
Bestellung. Diese Daten sind direkt in den ePayBL-Einstellungen der Veranstaltung unter Einstellungen -> Zahlung ->
|
||||
ePayBL vorzunehmen
|
||||
|
||||
In der Praxis bedeutet dies, dass in einem angeschlossenen Haushaltssystem nicht nachvollzogen kann, welche Positionen
|
||||
konkret erworben und bezahlt wurden - stattdessen kann nur der Fakt, dass etwas verkauft wurde erfasst werden.
|
||||
|
||||
Je nach Aufbau und Vorgaben der Finanzbuchhaltung kann dies jedoch ausreichend sein - wenn bspw. eine Ferienfahrt
|
||||
angeboten wird und seitens der Haushaltssysteme nicht erfasst werden muss, wie viel vom Gesamtbetrag einer Bestellung
|
||||
auf die Ferienfahrt an sich, auf einen Zubringerbus und einen Satz Bettwäsche entfallen ist, sondern (vereinfacht
|
||||
gesagt) es ausreichend ist, dass "Eine Summe X für die Haushaltsstelle/Objektnummer geflossen ist".
|
||||
|
||||
Dieser Modus der Kontierung bietet Ihnen auch als Vorteil gegenüber dem vorhergehenden an, dass die Bestellungen der
|
||||
Kunden jederzeit erweitert und verändert werden können - auch wenn die Ursprungsbestellung schon bezahlt wurde und nur
|
||||
noch eine Differenz gezahlt wird.
|
||||
|
||||
|
||||
Einschränkungen
|
||||
---------------
|
||||
|
||||
Zum aktuellen Zeitpunkt erlaubt die pretix-Anbindung an ePayBL nicht das durchführen von Erstattungen von bereits
|
||||
geleisteten Zahlungen. Der Prozess hierfür unterscheidet sich von Behörde zu Behörde und muss daher händisch
|
||||
durchgeführt werden.
|
||||
|
||||
.. _ePayBL: https://www.epaybl.de/
|
||||
@@ -35,7 +35,7 @@ contact_name string Contact person
|
||||
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``)
|
||||
booth string Booth number (or ``null``). Maximum 100 characters.
|
||||
locale string Locale for communication with the exhibitor (or ``null``).
|
||||
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).
|
||||
allow_lead_scanning boolean Enables lead scanning app
|
||||
allow_lead_access boolean Enables access to data gathered by the lead scanning app
|
||||
@@ -230,7 +230,8 @@ Endpoints
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/vouchers/
|
||||
|
||||
Returns a list of all vouchers connected to an exhibitor. The response contains the same data as described in
|
||||
:ref:`rest-vouchers`.
|
||||
:ref:`rest-vouchers` as well as for each voucher an additional field ``exhibitor_comment`` that is shown to the exhibitor. It can only
|
||||
be modified using the ``attach`` API call below.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -285,7 +286,7 @@ Endpoints
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/vouchers/attach/
|
||||
|
||||
Attaches an **existing** voucher to an exhibitor. You need to send either the ``id`` **or** the ``code`` field of
|
||||
the voucher.
|
||||
the voucher. You can call this method multiple times to update the optional ``exhibitor_comment`` field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -296,7 +297,8 @@ Endpoints
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"id": 15
|
||||
"id": 15,
|
||||
"exhibitor_comment": "Free ticket"
|
||||
}
|
||||
|
||||
**Example request**:
|
||||
@@ -308,7 +310,8 @@ Endpoints
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"code": "43K6LKM37FBVR2YG"
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
"exhibitor_comment": "Free ticket"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -356,7 +359,6 @@ Endpoints
|
||||
"contact_email": "johnson@as.example.org",
|
||||
"booth": "A2",
|
||||
"locale": "de",
|
||||
"access_code": "VKHZ2FU8",
|
||||
"allow_lead_scanning": true,
|
||||
"allow_lead_access": true,
|
||||
"allow_voucher_access": true,
|
||||
@@ -411,7 +413,7 @@ Endpoints
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/1/ HTTP/1.1
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
@@ -459,6 +461,36 @@ Endpoints
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/exhibitor does not exist **or** you have no permission to change it.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/send_access_code/
|
||||
|
||||
Sends an email to the exhibitor with their access code.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/send_access_code/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param code: The ``id`` field of the exhibitor to send an email for
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The exhibitor does not have an email address associated
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested exhibitor does not exist.
|
||||
:statuscode 503: The email could not be sent.
|
||||
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/
|
||||
|
||||
|
||||
BIN
doc/plugins/img/epaybl_flowchart.png
Normal file
BIN
doc/plugins/img/epaybl_flowchart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -18,6 +18,7 @@ If you want to **create** a plugin, please go to the
|
||||
campaigns
|
||||
certificates
|
||||
digital
|
||||
epaybl
|
||||
exhibitors
|
||||
shipping
|
||||
imported_secrets
|
||||
|
||||
BIN
doc/user/events/img/widget_checkbox_button.png
Normal file
BIN
doc/user/events/img/widget_checkbox_button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -124,6 +124,24 @@ If you want to disable voucher input in the widget, you can pass the ``disable-v
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" disable-vouchers></pretix-widget>
|
||||
|
||||
Enabling the button-style single item select
|
||||
--------------------------------------------
|
||||
|
||||
By default, the widget uses a checkbox to select items, that can only be bought in quantities of one. If you want to match
|
||||
the button-style of that checkbox with the one in the pretix shop, you can use the ``single-item-select`` attribute::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" single-item-select="button"></pretix-widget>
|
||||
|
||||
.. image:: img/widget_checkbox_button.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
.. note::
|
||||
|
||||
Due to compatibilty with existing widget installations, the default value for ``single-item-select``
|
||||
is ``checkbox``. This might change in the future, so make sure, to set the attribute to
|
||||
``single-item-select="checkbox"`` if you need it.
|
||||
|
||||
Filtering products
|
||||
------------------
|
||||
|
||||
@@ -293,6 +311,16 @@ with that information::
|
||||
</pretix-widget>
|
||||
|
||||
This works for the pretix Button as well, if you also specify a product.
|
||||
|
||||
As data-attributes are reactive, you can change them with JavaScript as well. Please note, that once the user
|
||||
started the checkout process, we do not update the data-attributes in the existing checkout process to not
|
||||
interrupt the checkout UX.
|
||||
|
||||
When updating data-attributes through JavaScript, make sure you do not have a stale reference to the HTMLNode of the
|
||||
widget. When the widget is created, the original HTMLNode can happen to be replaced. So make sure to always have a
|
||||
fresh reference like so
|
||||
``document.querySelectorAll("pretix-widget, pretix-button, .pretix-widget-wrapper")``
|
||||
|
||||
Currently, the following attributes are understood by pretix itself:
|
||||
|
||||
* ``data-email`` will pre-fill the order email field as well as the attendee email field (if enabled).
|
||||
@@ -329,125 +357,72 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
* If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders
|
||||
made through this widget will be counted towards this campaign.
|
||||
|
||||
* If you use the tracking plugin, you can enable cross-domain tracking. To do so, you need to initialize the
|
||||
pretix-widget manually. Use the html code to embed the widget and add one the following code snippets. Make sure to
|
||||
replace all occurrences of <MEASUREMENT_ID> with your Google Analytics MEASUREMENT_ID (UA-XXXXXXX-X or G-XXXXXXXX)
|
||||
* If you use the tracking plugin, you can enable cross-domain tracking. Please note: when you run your pretix-shop on a
|
||||
subdomain of your main tracking domain, then you do not need cross-domain tracking as tracking automatically works
|
||||
across subdomains. See :ref:`custom_domain` for how to set this up.
|
||||
|
||||
Please also make sure to add the embedding website to your `Referral exclusions
|
||||
Please make sure to add the embedding website to your `Referral exclusions
|
||||
<https://support.google.com/analytics/answer/2795830>`_ in your Google Analytics settings.
|
||||
|
||||
If you use Google Analytics 4 (GA4 – G-XXXXXXXX)::
|
||||
Add Google Analytics as you normally would with all your `window.dataLayer` and `gtag` configurations. Also add the
|
||||
widget code normally. Then you have two options:
|
||||
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=<MEASUREMENT_ID>"></script>
|
||||
<script type="text/javascript">
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '<MEASUREMENT_ID>');
|
||||
* Block loading of the widget at most 2 seconds or until Google’s client- and session-ID are loaded. This method
|
||||
uses `window.pretixWidgetCallback`. Note that if it takes longer than 2 seconds to load, client- and session-ID
|
||||
are never passed to the widget. Make sure to replace all occurrences of <MEASUREMENT_ID> with your Google
|
||||
Analytics MEASUREMENT_ID (G-XXXXXXXX)::
|
||||
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||
if (!window['google_tag_manager']) {
|
||||
window.PretixWidget.buildWidgets();
|
||||
return;
|
||||
}
|
||||
<script type="text/javascript">
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||
if (!window['google_tag_manager']) {
|
||||
window.PretixWidget.buildWidgets();
|
||||
return;
|
||||
}
|
||||
|
||||
var clientId;
|
||||
var sessionId;
|
||||
var loadingTimeout;
|
||||
function build() {
|
||||
// use loadingTimeout to make sure build() is only called once
|
||||
if (!loadingTimeout) return;
|
||||
window.clearTimeout(loadingTimeout);
|
||||
loadingTimeout = null;
|
||||
if (clientId) window.PretixWidget.widget_data["tracking-ga-id"] = clientId;
|
||||
if (sessionId) window.PretixWidget.widget_data["tracking-ga-sessid"] = sessionId;
|
||||
window.PretixWidget.buildWidgets();
|
||||
};
|
||||
// make sure to build pretix-widgets if gtag fails to load either client_id or session_id
|
||||
loadingTimeout = window.setTimeout(build, 2000);
|
||||
var clientId;
|
||||
var sessionId;
|
||||
var loadingTimeout;
|
||||
function build() {
|
||||
// use loadingTimeout to make sure build() is only called once
|
||||
if (!loadingTimeout) return;
|
||||
window.clearTimeout(loadingTimeout);
|
||||
loadingTimeout = null;
|
||||
if (clientId) window.PretixWidget.widget_data["tracking-ga-id"] = clientId;
|
||||
if (sessionId) window.PretixWidget.widget_data["tracking-ga-sessid"] = sessionId;
|
||||
window.PretixWidget.buildWidgets();
|
||||
};
|
||||
// make sure to build pretix-widgets if gtag fails to load either client_id or session_id
|
||||
loadingTimeout = window.setTimeout(build, 2000);
|
||||
|
||||
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
|
||||
clientId = id;
|
||||
if (sessionId !== undefined) build();
|
||||
});
|
||||
gtag('get', '<MEASUREMENT_ID>', 'session_id', function(id) {
|
||||
sessionId = id;
|
||||
if (clientId !== undefined) build();
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
* Or asynchronously set data-attributes – the widgets are shown immediately, but once the user has started checkout,
|
||||
data-attributes are not updated. Make sure to replace all occurrences of <MEASUREMENT_ID> with your Google
|
||||
Analytics MEASUREMENT_ID (G-XXXXXXXX)::
|
||||
|
||||
<script type="text/javascript">
|
||||
window.addEventListener('load', function() {
|
||||
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
|
||||
clientId = id;
|
||||
if (sessionId !== undefined) build();
|
||||
const widgets = document.querySelectorAll("pretix-widget, pretix-button, .pretix-widget-wrapper");
|
||||
widgets.forEach(widget => widget.setAttribute("data-tracking-ga-id", id))
|
||||
});
|
||||
gtag('get', '<MEASUREMENT_ID>', 'session_id', function(id) {
|
||||
sessionId = id;
|
||||
if (clientId !== undefined) build();
|
||||
const widgets = document.querySelectorAll("pretix-widget, pretix-button, .pretix-widget-wrapper");
|
||||
widgets.forEach(widget => widget.setAttribute("data-tracking-ga-sessid", id))
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
If you use Universal Analytics with ``gtag.js`` (UA-XXXXXXX-X)::
|
||||
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=<MEASUREMENT_ID>"></script>
|
||||
<script type="text/javascript">
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '<MEASUREMENT_ID>');
|
||||
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||
if (!window['google_tag_manager']) {
|
||||
window.PretixWidget.buildWidgets();
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure to build pretix-widgets if gtag fails to load client_id
|
||||
var loadingTimeout = window.setTimeout(function() {
|
||||
loadingTimeout = null;
|
||||
window.PretixWidget.buildWidgets();
|
||||
}, 1000);
|
||||
|
||||
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
|
||||
if (loadingTimeout) {
|
||||
window.clearTimeout(loadingTimeout);
|
||||
window.PretixWidget.widget_data["tracking-ga-id"] = id;
|
||||
window.PretixWidget.buildWidgets();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
If you use ``analytics.js`` (Universal Analytics)::
|
||||
|
||||
<script>
|
||||
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
||||
|
||||
ga('create', '<MEASUREMENT_ID>', 'auto');
|
||||
ga('send', 'pageview');
|
||||
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||
if (!window['ga'] || !ga.create) {
|
||||
// Tracking is probably blocked
|
||||
window.PretixWidget.buildWidgets()
|
||||
return;
|
||||
}
|
||||
|
||||
var loadingTimeout = window.setTimeout(function() {
|
||||
loadingTimeout = null;
|
||||
window.PretixWidget.buildWidgets();
|
||||
}, 1000);
|
||||
ga(function(tracker) {
|
||||
if (loadingTimeout) {
|
||||
window.clearTimeout(loadingTimeout);
|
||||
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
|
||||
window.PretixWidget.buildWidgets();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
</script>
|
||||
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Framework :: Django :: 3.2",
|
||||
"Framework :: Django :: 4.1",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
@@ -30,13 +30,13 @@ dependencies = [
|
||||
"babel",
|
||||
"BeautifulSoup4==4.12.*",
|
||||
"bleach==5.0.*",
|
||||
"celery==5.2.*",
|
||||
"celery==5.3.*",
|
||||
"chardet==5.1.*",
|
||||
"cryptography>=3.4.2",
|
||||
"css-inline==0.8.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"dj-static",
|
||||
"Django==3.2.*,>=3.2.18",
|
||||
"Django==4.2.*",
|
||||
"django-bootstrap3==23.1.*",
|
||||
"django-compressor==4.3.*",
|
||||
"django-countries==7.5.*",
|
||||
@@ -49,9 +49,8 @@ dependencies = [
|
||||
"django-libsass==0.9",
|
||||
"django-localflavor==4.0",
|
||||
"django-markup",
|
||||
"django-mysql",
|
||||
"django-oauth-toolkit==2.2.*",
|
||||
"django-otp==1.1.*",
|
||||
"django-otp==1.2.*",
|
||||
"django-phonenumber-field==7.1.*",
|
||||
"django-redis==5.2.*",
|
||||
"django-scopes==2.0.*",
|
||||
@@ -60,38 +59,40 @@ dependencies = [
|
||||
"dnspython==2.3.*",
|
||||
"drf_ujson2==1.7.*",
|
||||
"geoip2==4.*",
|
||||
"importlib_metadata==6.6.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||
"importlib_metadata==7.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||
"isoweek",
|
||||
"jsonschema",
|
||||
"kombu==5.2.*",
|
||||
"kombu==5.3.*",
|
||||
"libsass==0.22.*",
|
||||
"lxml",
|
||||
"markdown==3.4.3", # 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.23.*",
|
||||
"mt-940==4.30.*",
|
||||
"oauthlib==3.2.*",
|
||||
"openpyxl==3.1.*",
|
||||
"packaging",
|
||||
"paypalrestsdk==1.13.*",
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.6.*",
|
||||
"PyJWT==2.7.*",
|
||||
"phonenumberslite==8.13.*",
|
||||
"Pillow==9.5.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==4.23.*",
|
||||
"psycopg2-binary",
|
||||
"pycountry",
|
||||
"pycparser==2.21",
|
||||
"pycryptodome==3.18.*",
|
||||
"pypdf==3.8.*",
|
||||
"pypdf==3.9.*",
|
||||
"python-bidi==0.4.*", # Support for Arabic in reportlab
|
||||
"python-dateutil==2.8.*",
|
||||
"python-u2flib-server==4.*",
|
||||
"pytz",
|
||||
"pytz-deprecation-shim==0.1.*",
|
||||
"pyuca",
|
||||
"qrcode==7.4.*",
|
||||
"redis==4.5.*,>=4.5.4",
|
||||
"redis==4.6.*",
|
||||
"reportlab==4.0.*",
|
||||
"requests==2.30.*",
|
||||
"requests==2.31.*",
|
||||
"sentry-sdk==1.15.*",
|
||||
"sepaxml==2.6.*",
|
||||
"slimit",
|
||||
@@ -108,10 +109,11 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
memcached = ["pylibmc"]
|
||||
mysql = ["mysqlclient"]
|
||||
dev = [
|
||||
"aiohttp==3.8.*",
|
||||
"coverage",
|
||||
"coveralls",
|
||||
"fakeredis==2.18.*",
|
||||
"flake8==6.0.*",
|
||||
"freezegun",
|
||||
"isort==5.12.*",
|
||||
@@ -119,13 +121,14 @@ dev = [
|
||||
"potypo",
|
||||
"pycodestyle==2.10.*",
|
||||
"pyflakes==3.0.*",
|
||||
"pytest-asyncio",
|
||||
"pytest-cache",
|
||||
"pytest-cov",
|
||||
"pytest-django==4.*",
|
||||
"pytest-mock==3.10.*",
|
||||
"pytest-rerunfailures==11.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.2.*",
|
||||
"pytest-xdist==3.3.*",
|
||||
"pytest==7.3.*",
|
||||
"responses",
|
||||
]
|
||||
|
||||
1
setup.py
1
setup.py
@@ -29,7 +29,6 @@ sys.path.append(str(Path.cwd() / 'src'))
|
||||
|
||||
|
||||
def _CustomBuild(*args, **kwargs):
|
||||
print(sys.path)
|
||||
from pretix._build import CustomBuild
|
||||
return CustomBuild(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -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__ = "4.21.0.dev0"
|
||||
__version__ = "2023.9.1"
|
||||
|
||||
@@ -30,7 +30,6 @@ from django.utils.translation import gettext_lazy as _ # NOQA
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
|
||||
INSTALLED_APPS = [
|
||||
@@ -68,6 +67,7 @@ INSTALLED_APPS = [
|
||||
'oauth2_provider',
|
||||
'phonenumber_field',
|
||||
'statici18n',
|
||||
'django.forms', # after pretix.base for overrides
|
||||
]
|
||||
|
||||
FORMAT_MODULE_PATH = [
|
||||
@@ -89,6 +89,7 @@ ALL_LANGUAGES = [
|
||||
('fi', _('Finnish')),
|
||||
('gl', _('Galician')),
|
||||
('el', _('Greek')),
|
||||
('id', _('Indonesian')),
|
||||
('it', _('Italian')),
|
||||
('lv', _('Latvian')),
|
||||
('pl', _('Polish')),
|
||||
@@ -180,6 +181,8 @@ TEMPLATES = [
|
||||
},
|
||||
]
|
||||
|
||||
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
|
||||
|
||||
STATIC_ROOT = os.path.join(os.path.dirname(__file__), 'static.dist')
|
||||
|
||||
STATICFILES_FINDERS = (
|
||||
@@ -194,7 +197,14 @@ STATICFILES_DIRS = [
|
||||
|
||||
STATICI18N_ROOT = os.path.join(BASE_DIR, "pretix/static")
|
||||
|
||||
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
# if os.path.exists(os.path.join(DATA_DIR, 'static')):
|
||||
# STATICFILES_DIRS.insert(0, os.path.join(DATA_DIR, 'static'))
|
||||
@@ -250,3 +260,20 @@ PRETIX_PRIMARY_COLOR = '#8E44B3'
|
||||
# stressful for some cache setups so it is enabled by default and currently can't be enabled through pretix.cfg
|
||||
CACHE_LARGE_VALUES_ALLOWED = False
|
||||
CACHE_LARGE_VALUES_ALIAS = 'default'
|
||||
|
||||
# Allowed file extensions for various places plus matching Pillow formats.
|
||||
# Never allow EPS, it is full of dangerous bugs.
|
||||
FILE_UPLOAD_EXTENSIONS_IMAGE = (".png", ".jpg", ".gif", ".jpeg")
|
||||
PILLOW_FORMATS_IMAGE = ('PNG', 'GIF', 'JPEG')
|
||||
|
||||
FILE_UPLOAD_EXTENSIONS_FAVICON = (".ico", ".png", "jpg", ".gif", ".jpeg")
|
||||
|
||||
FILE_UPLOAD_EXTENSIONS_QUESTION_IMAGE = (".png", "jpg", ".gif", ".jpeg", ".bmp", ".tif", ".tiff", ".jfif")
|
||||
PILLOW_FORMATS_QUESTIONS_IMAGE = ('PNG', 'GIF', 'JPEG', 'BMP', 'TIFF')
|
||||
|
||||
FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT = (
|
||||
".png", ".jpg", ".gif", ".jpeg", ".pdf", ".txt", ".docx", ".gif", ".svg",
|
||||
".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages",
|
||||
".bmp", ".tif", ".tiff"
|
||||
)
|
||||
FILE_UPLOAD_EXTENSIONS_OTHER = FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT
|
||||
|
||||
@@ -45,6 +45,10 @@ def npm_install():
|
||||
|
||||
class CustomBuild(build):
|
||||
def run(self):
|
||||
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
|
||||
# Only run this command on the pretix module, not on other modules even if it's registered globally
|
||||
# in some cases
|
||||
return build.run(self)
|
||||
if "PRETIX_DOCKER_BUILD" in os.environ:
|
||||
return # this is a hack to allow calling this file early in our docker build to make use of caching
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix._build_settings")
|
||||
@@ -68,6 +72,10 @@ class CustomBuild(build):
|
||||
|
||||
class CustomBuildExt(build_ext):
|
||||
def run(self):
|
||||
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
|
||||
# Only run this command on the pretix module, not on other modules even if it's registered globally
|
||||
# in some cases
|
||||
return build_ext.run(self)
|
||||
if "PRETIX_DOCKER_BUILD" in os.environ:
|
||||
return # this is a hack to allow calling this file early in our docker build to make use of caching
|
||||
npm_install()
|
||||
|
||||
@@ -223,6 +223,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
('GET', 'api-v1:checkinrpc.search'),
|
||||
('POST', 'api-v1:reusablemedium-lookup'),
|
||||
('POST', 'api-v1:reusablemedium-list'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ class IdempotencyMiddleware:
|
||||
auth_hash = sha1(auth_hash_parts.encode()).hexdigest()
|
||||
idempotency_key = request.headers.get('X-Idempotency-Key', '')
|
||||
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(durable=True):
|
||||
call, created = ApiCall.objects.select_for_update(of=OF_SELF).get_or_create(
|
||||
auth_hash=auth_hash,
|
||||
idempotency_key=idempotency_key,
|
||||
@@ -75,7 +75,7 @@ class IdempotencyMiddleware:
|
||||
|
||||
if created:
|
||||
resp = self.get_response(request)
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(durable=True):
|
||||
if resp.status_code in (409, 429, 500, 503):
|
||||
# This is the exception: These calls are *meant* to be retried!
|
||||
call.delete()
|
||||
|
||||
91
src/pretix/api/migrations/0011_bigint.py
Normal file
91
src/pretix/api/migrations/0011_bigint.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# Generated by Django 4.2.4 on 2023-09-26 12:01
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("pretixapi", "0010_webhook_comment"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="apicall",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauthaccesstoken",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="%(app_label)s_%(class)s",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauthapplication",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="%(app_label)s_%(class)s",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauthgrant",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="%(app_label)s_%(class)s",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauthidtoken",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="%(app_label)s_%(class)s",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauthrefreshtoken",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="%(app_label)s_%(class)s",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="webhook",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="webhookcall",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="webhookeventlistener",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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 json
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
@@ -46,3 +48,16 @@ class AsymmetricField(serializers.Field):
|
||||
|
||||
def run_validation(self, data=serializers.empty):
|
||||
return self.write.run_validation(data)
|
||||
|
||||
|
||||
class CompatibleJSONField(serializers.JSONField):
|
||||
def to_internal_value(self, data):
|
||||
try:
|
||||
return json.dumps(data)
|
||||
except (TypeError, ValueError):
|
||||
self.fail('invalid')
|
||||
|
||||
def to_representation(self, value):
|
||||
if value:
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
@@ -38,7 +38,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
|
||||
model = CheckinList
|
||||
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
|
||||
'include_pending', 'auto_checkin_sales_channels', 'allow_multiple_entries', 'allow_entry_after_exit',
|
||||
'rules', 'exit_all_at', 'addon_match')
|
||||
'rules', 'exit_all_at', 'addon_match', 'ignore_in_statistics', 'consider_tickets_used')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -32,11 +32,13 @@ class DiscountSerializer(I18nAwareModelSerializer):
|
||||
'available_until', 'subevent_mode', 'condition_all_products', 'condition_limit_products',
|
||||
'condition_apply_to_addons', 'condition_min_count', 'condition_min_value',
|
||||
'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches',
|
||||
'condition_ignore_voucher_discounted')
|
||||
'benefit_same_products', 'benefit_limit_products', 'benefit_apply_to_addons',
|
||||
'benefit_ignore_voucher_discounted', 'condition_ignore_voucher_discounted')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['condition_limit_products'].queryset = self.context['event'].items.all()
|
||||
self.fields['benefit_limit_products'].queryset = self.context['event'].items.all()
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
@@ -46,6 +46,7 @@ from rest_framework import serializers
|
||||
from rest_framework.fields import ChoiceField, Field
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
|
||||
from pretix.api.serializers import CompatibleJSONField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.settings import SettingsSerializer
|
||||
from pretix.base.models import Device, Event, TaxRule, TeamAPIToken
|
||||
@@ -53,6 +54,7 @@ from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import (
|
||||
ItemMetaProperty, SubEventItem, SubEventItemVariation,
|
||||
)
|
||||
from pretix.base.models.tax import CustomRulesValidator
|
||||
from pretix.base.services.seating import (
|
||||
SeatProtected, generate_seats, validate_plan_change,
|
||||
)
|
||||
@@ -650,9 +652,16 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
|
||||
|
||||
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
custom_rules = CompatibleJSONField(
|
||||
validators=[CustomRulesValidator()],
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = TaxRule
|
||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes')
|
||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name',
|
||||
'keep_gross_if_rate_changes', 'custom_rules')
|
||||
|
||||
|
||||
class EventSettingsSerializer(SettingsSerializer):
|
||||
@@ -719,6 +728,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'payment_term_minutes',
|
||||
'payment_term_last',
|
||||
'payment_term_expire_automatically',
|
||||
'payment_term_expire_delay_days',
|
||||
'payment_term_accept_late',
|
||||
'payment_explanation',
|
||||
'payment_pending_hidden',
|
||||
@@ -807,6 +817,10 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'reusable_media_type_nfc_uid',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
|
||||
'reusable_media_type_nfc_mf0aes',
|
||||
'reusable_media_type_nfc_mf0aes_autocreate_giftcard',
|
||||
'reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency',
|
||||
'reusable_media_type_nfc_mf0aes_random_uid',
|
||||
]
|
||||
readonly_fields = [
|
||||
# These are read-only since they are currently only settable on organizers, not events
|
||||
@@ -816,6 +830,10 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'reusable_media_type_nfc_uid',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
|
||||
'reusable_media_type_nfc_mf0aes',
|
||||
'reusable_media_type_nfc_mf0aes_autocreate_giftcard',
|
||||
'reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency',
|
||||
'reusable_media_type_nfc_mf0aes_random_uid',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -884,6 +902,8 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'name_scheme',
|
||||
'reusable_media_type_barcode',
|
||||
'reusable_media_type_nfc_uid',
|
||||
'reusable_media_type_nfc_mf0aes',
|
||||
'reusable_media_type_nfc_mf0aes_random_uid',
|
||||
'system_question_order',
|
||||
]
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ class JobRunSerializer(serializers.Serializer):
|
||||
if events is not None and not isinstance(ex, OrganizerLevelExportMixin):
|
||||
self.fields["events"] = serializers.SlugRelatedField(
|
||||
queryset=events,
|
||||
required=True,
|
||||
required=False,
|
||||
allow_empty=False,
|
||||
slug_field='slug',
|
||||
many=True
|
||||
@@ -156,8 +156,9 @@ class JobRunSerializer(serializers.Serializer):
|
||||
def to_internal_value(self, data):
|
||||
if isinstance(data, QueryDict):
|
||||
data = data.copy()
|
||||
|
||||
for k, v in self.fields.items():
|
||||
if isinstance(v, serializers.ManyRelatedField) and k not in data:
|
||||
if isinstance(v, serializers.ManyRelatedField) and k not in data and k != "events":
|
||||
data[k] = []
|
||||
|
||||
for fk in self.fields.keys():
|
||||
|
||||
@@ -60,6 +60,8 @@ class NestedGiftCardSerializer(GiftCardSerializer):
|
||||
|
||||
|
||||
class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -111,6 +113,7 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
model = ReusableMedium
|
||||
fields = (
|
||||
'id',
|
||||
'organizer',
|
||||
'created',
|
||||
'updated',
|
||||
'type',
|
||||
|
||||
@@ -19,15 +19,16 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pycountry
|
||||
from django.conf import settings
|
||||
from django.core.files import File
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.timezone import now
|
||||
@@ -39,6 +40,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from pretix.api.serializers import CompatibleJSONField
|
||||
from pretix.api.serializers.event import SubEventSerializer
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.item import (
|
||||
@@ -58,10 +60,11 @@ from pretix.base.models.orders import (
|
||||
)
|
||||
from pretix.base.pdf import get_images, get_variables
|
||||
from pretix.base.services.cart import error_messages
|
||||
from pretix.base.services.locking import NoLockManager
|
||||
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
|
||||
from pretix.base.services.pricing import (
|
||||
apply_discounts, get_line_price, get_listed_price, is_included_for_free,
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
@@ -283,11 +286,12 @@ class FailedCheckinSerializer(I18nAwareModelSerializer):
|
||||
raw_item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
|
||||
raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
|
||||
raw_subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True)
|
||||
nonce = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = ('error_reason', 'error_explanation', 'raw_barcode', 'raw_item', 'raw_variation',
|
||||
'raw_subevent', 'datetime', 'type', 'position')
|
||||
'raw_subevent', 'nonce', 'datetime', 'type', 'position')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -372,11 +376,15 @@ class PdfDataSerializer(serializers.Field):
|
||||
self.context['vars_images'] = get_images(self.context['event'])
|
||||
|
||||
for k, f in self.context['vars'].items():
|
||||
try:
|
||||
res[k] = f['evaluate'](instance, instance.order, ev)
|
||||
except:
|
||||
logger.exception('Evaluating PDF variable failed')
|
||||
res[k] = '(error)'
|
||||
if 'evaluate_bulk' in f:
|
||||
# Will be evaluated later by our list serializers
|
||||
res[k] = (f['evaluate_bulk'], instance)
|
||||
else:
|
||||
try:
|
||||
res[k] = f['evaluate'](instance, instance.order, ev)
|
||||
except:
|
||||
logger.exception('Evaluating PDF variable failed')
|
||||
res[k] = '(error)'
|
||||
|
||||
if not hasattr(ev, '_cached_meta_data'):
|
||||
ev._cached_meta_data = ev.meta_data
|
||||
@@ -429,6 +437,38 @@ class PdfDataSerializer(serializers.Field):
|
||||
return res
|
||||
|
||||
|
||||
class OrderPositionListSerializer(serializers.ListSerializer):
|
||||
|
||||
def to_representation(self, data):
|
||||
# We have a custom implementation of this method because PdfDataSerializer() might keep some elements unevaluated
|
||||
# with a (callable, input) tuple. We'll loop over these entries and evaluate them bulk-wise to save on SQL queries.
|
||||
|
||||
if isinstance(self.parent, OrderSerializer) and isinstance(self.parent.parent, OrderListSerializer):
|
||||
# Do not execute our custom code because it will be executed by OrderListSerializer later for the
|
||||
# full result set.
|
||||
return super().to_representation(data)
|
||||
|
||||
iterable = data.all() if isinstance(data, models.Manager) else data
|
||||
|
||||
data = []
|
||||
evaluate_queue = defaultdict(list)
|
||||
|
||||
for item in iterable:
|
||||
entry = self.child.to_representation(item)
|
||||
if "pdf_data" in entry:
|
||||
for k, v in entry["pdf_data"].items():
|
||||
if isinstance(v, tuple) and callable(v[0]):
|
||||
evaluate_queue[v[0]].append((v[1], entry, k))
|
||||
data.append(entry)
|
||||
|
||||
for func, entries in evaluate_queue.items():
|
||||
results = func([item for (item, entry, k) in entries])
|
||||
for (item, entry, k), result in zip(entries, results):
|
||||
entry["pdf_data"][k] = result
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
checkins = CheckinSerializer(many=True, read_only=True)
|
||||
answers = AnswerSerializer(many=True)
|
||||
@@ -440,6 +480,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
attendee_name = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
list_serializer_class = OrderPositionListSerializer
|
||||
model = OrderPosition
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
|
||||
@@ -468,6 +509,20 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
def validate(self, data):
|
||||
raise TypeError("this serializer is readonly")
|
||||
|
||||
def to_representation(self, data):
|
||||
if isinstance(self.parent, (OrderListSerializer, OrderPositionListSerializer)):
|
||||
# Do not execute our custom code because it will be executed by OrderListSerializer later for the
|
||||
# full result set.
|
||||
return super().to_representation(data)
|
||||
|
||||
entry = super().to_representation(data)
|
||||
if "pdf_data" in entry:
|
||||
for k, v in entry["pdf_data"].items():
|
||||
if isinstance(v, tuple) and callable(v[0]):
|
||||
entry["pdf_data"][k] = v[0]([v[1]])[0]
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
class RequireAttentionField(serializers.Field):
|
||||
def to_representation(self, instance: OrderPosition):
|
||||
@@ -535,8 +590,9 @@ class OrderPaymentTypeField(serializers.Field):
|
||||
# TODO: Remove after pretix 2.2
|
||||
def to_representation(self, instance: Order):
|
||||
t = None
|
||||
for p in instance.payments.all():
|
||||
t = p.provider
|
||||
if instance.pk:
|
||||
for p in instance.payments.all():
|
||||
t = p.provider
|
||||
return t
|
||||
|
||||
|
||||
@@ -544,10 +600,10 @@ class OrderPaymentDateField(serializers.DateField):
|
||||
# TODO: Remove after pretix 2.2
|
||||
def to_representation(self, instance: Order):
|
||||
t = None
|
||||
for p in instance.payments.all():
|
||||
t = p.payment_date or t
|
||||
if instance.pk:
|
||||
for p in instance.payments.all():
|
||||
t = p.payment_date or t
|
||||
if t:
|
||||
|
||||
return super().to_representation(t.date())
|
||||
|
||||
|
||||
@@ -561,7 +617,7 @@ class PaymentURLField(serializers.URLField):
|
||||
def to_representation(self, instance: OrderPayment):
|
||||
if instance.state != OrderPayment.PAYMENT_STATE_CREATED:
|
||||
return None
|
||||
return build_absolute_uri(self.context['event'], 'presale:event.order.pay', kwargs={
|
||||
return build_absolute_uri(instance.order.event, 'presale:event.order.pay', kwargs={
|
||||
'order': instance.order.code,
|
||||
'secret': instance.order.secret,
|
||||
'payment': instance.pk,
|
||||
@@ -606,13 +662,42 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class OrderURLField(serializers.URLField):
|
||||
def to_representation(self, instance: Order):
|
||||
return build_absolute_uri(self.context['event'], 'presale:event.order', kwargs={
|
||||
return build_absolute_uri(instance.event, 'presale:event.order', kwargs={
|
||||
'order': instance.code,
|
||||
'secret': instance.secret,
|
||||
})
|
||||
|
||||
|
||||
class OrderListSerializer(serializers.ListSerializer):
|
||||
|
||||
def to_representation(self, data):
|
||||
# We have a custom implementation of this method because PdfDataSerializer() might keep some elements
|
||||
# unevaluated with a (callable, input) tuple. We'll loop over these entries and evaluate them bulk-wise to
|
||||
# save on SQL queries.
|
||||
iterable = data.all() if isinstance(data, models.Manager) else data
|
||||
|
||||
data = []
|
||||
evaluate_queue = defaultdict(list)
|
||||
|
||||
for item in iterable:
|
||||
entry = self.child.to_representation(item)
|
||||
for p in entry.get("positions", []):
|
||||
if "pdf_data" in p:
|
||||
for k, v in p["pdf_data"].items():
|
||||
if isinstance(v, tuple) and callable(v[0]):
|
||||
evaluate_queue[v[0]].append((v[1], p, k))
|
||||
data.append(entry)
|
||||
|
||||
for func, entries in evaluate_queue.items():
|
||||
results = func([item for (item, entry, k) in entries])
|
||||
for (item, entry, k), result in zip(entries, results):
|
||||
entry["pdf_data"][k] = result
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class OrderSerializer(I18nAwareModelSerializer):
|
||||
event = SlugRelatedField(slug_field='slug', read_only=True)
|
||||
invoice_address = InvoiceAddressSerializer(allow_null=True)
|
||||
positions = OrderPositionSerializer(many=True, read_only=True)
|
||||
fees = OrderFeeSerializer(many=True, read_only=True)
|
||||
@@ -626,8 +711,9 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
list_serializer_class = OrderListSerializer
|
||||
fields = (
|
||||
'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
|
||||
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
|
||||
'url', 'customer', 'valid_if_pending'
|
||||
@@ -895,19 +981,6 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class CompatibleJSONField(serializers.JSONField):
|
||||
def to_internal_value(self, data):
|
||||
try:
|
||||
return json.dumps(data)
|
||||
except (TypeError, ValueError):
|
||||
self.fail('invalid')
|
||||
|
||||
def to_representation(self, value):
|
||||
if value:
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
|
||||
class WrappedList:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
@@ -1073,337 +1146,367 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
else:
|
||||
ia = None
|
||||
|
||||
lock_required = False
|
||||
quotas_by_item = {}
|
||||
quota_diff_for_locking = Counter()
|
||||
voucher_diff_for_locking = Counter()
|
||||
seat_diff_for_locking = Counter()
|
||||
quota_usage = Counter()
|
||||
voucher_usage = Counter()
|
||||
seat_usage = Counter()
|
||||
v_budget = {}
|
||||
now_dt = now()
|
||||
delete_cps = []
|
||||
consume_carts = validated_data.pop('consume_carts', [])
|
||||
|
||||
for pos_data in positions_data:
|
||||
pos_data['_quotas'] = list(
|
||||
pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
|
||||
if pos_data.get('variation')
|
||||
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent'))
|
||||
)
|
||||
if pos_data.get('voucher') or pos_data.get('seat') or any(q.size is not None for q in pos_data['_quotas']):
|
||||
lock_required = True
|
||||
if (pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent')) not in quotas_by_item:
|
||||
quotas_by_item[pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent')] = list(
|
||||
pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
|
||||
if pos_data.get('variation')
|
||||
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent'))
|
||||
)
|
||||
for q in quotas_by_item[pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent')]:
|
||||
quota_diff_for_locking[q] += 1
|
||||
if pos_data.get('voucher'):
|
||||
voucher_diff_for_locking[pos_data['voucher']] += 1
|
||||
if pos_data.get('seat'):
|
||||
try:
|
||||
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
pos_data['seat'] = Seat.DoesNotExist
|
||||
else:
|
||||
pos_data['seat'] = seat
|
||||
seat_diff_for_locking[pos_data['seat']] += 1
|
||||
|
||||
lockfn = self.context['event'].lock
|
||||
if simulate or not lock_required:
|
||||
lockfn = NoLockManager
|
||||
with lockfn() as now_dt:
|
||||
free_seats = set()
|
||||
seats_seen = set()
|
||||
consume_carts = validated_data.pop('consume_carts', [])
|
||||
delete_cps = []
|
||||
quota_avail_cache = {}
|
||||
v_budget = {}
|
||||
voucher_usage = Counter()
|
||||
if consume_carts:
|
||||
for cp in CartPosition.objects.filter(
|
||||
event=self.context['event'], cart_id__in=consume_carts, expires__gt=now()
|
||||
):
|
||||
quotas = (cp.variation.quotas.filter(subevent=cp.subevent)
|
||||
if cp.variation else cp.item.quotas.filter(subevent=cp.subevent))
|
||||
for quota in quotas:
|
||||
if quota not in quota_avail_cache:
|
||||
quota_avail_cache[quota] = list(quota.availability())
|
||||
if quota_avail_cache[quota][1] is not None:
|
||||
quota_avail_cache[quota][1] += 1
|
||||
if cp.voucher:
|
||||
voucher_usage[cp.voucher] -= 1
|
||||
if cp.expires > now_dt:
|
||||
if cp.seat:
|
||||
free_seats.add(cp.seat)
|
||||
delete_cps.append(cp)
|
||||
if consume_carts:
|
||||
offset = now() + timedelta(seconds=LOCK_TRUST_WINDOW)
|
||||
for cp in CartPosition.objects.filter(
|
||||
event=self.context['event'], cart_id__in=consume_carts, expires__gt=now_dt
|
||||
):
|
||||
quotas = (cp.variation.quotas.filter(subevent=cp.subevent)
|
||||
if cp.variation else cp.item.quotas.filter(subevent=cp.subevent))
|
||||
for quota in quotas:
|
||||
if cp.expires > offset:
|
||||
quota_diff_for_locking[quota] -= 1
|
||||
quota_usage[quota] -= 1
|
||||
if cp.voucher:
|
||||
if cp.expires > offset:
|
||||
voucher_diff_for_locking[cp.voucher] -= 1
|
||||
voucher_usage[cp.voucher] -= 1
|
||||
if cp.seat:
|
||||
if cp.expires > offset:
|
||||
seat_diff_for_locking[cp.seat] -= 1
|
||||
seat_usage[cp.seat] -= 1
|
||||
delete_cps.append(cp)
|
||||
|
||||
errs = [{} for p in positions_data]
|
||||
if not simulate:
|
||||
full_lock_required = seat_diff_for_locking and self.context['event'].settings.seating_minimal_distance > 0
|
||||
if full_lock_required:
|
||||
# We lock the entire event in this case since we don't want to deal with fine-granular locking
|
||||
# in the case of seating distance enforcement
|
||||
lock_objects([self.context['event']])
|
||||
else:
|
||||
lock_objects(
|
||||
[q for q, d in quota_diff_for_locking.items() if d > 0 and q.size is not None and not force] +
|
||||
[v for v, d in voucher_diff_for_locking.items() if d > 0 and not force] +
|
||||
[s for s, d in seat_diff_for_locking.items() if d > 0],
|
||||
shared_lock_objects=[self.context['event']]
|
||||
)
|
||||
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
qa = QuotaAvailability()
|
||||
qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0])
|
||||
qa.compute()
|
||||
|
||||
if pos_data.get('voucher'):
|
||||
v = pos_data['voucher']
|
||||
# These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental
|
||||
# use further down
|
||||
del quota_diff_for_locking, voucher_diff_for_locking, seat_diff_for_locking
|
||||
|
||||
if pos_data.get('addon_to'):
|
||||
errs[i]['voucher'] = ['Vouchers are currently not supported for add-on products.']
|
||||
continue
|
||||
errs = [{} for p in positions_data]
|
||||
|
||||
if not v.applies_to(pos_data['item'], pos_data.get('variation')):
|
||||
errs[i]['voucher'] = [error_messages['voucher_invalid_item']]
|
||||
continue
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
if pos_data.get('voucher'):
|
||||
v = pos_data['voucher']
|
||||
|
||||
if v.subevent_id and pos_data.get('subevent').pk != v.subevent_id:
|
||||
errs[i]['voucher'] = [error_messages['voucher_invalid_subevent']]
|
||||
continue
|
||||
if pos_data.get('addon_to'):
|
||||
errs[i]['voucher'] = ['Vouchers are currently not supported for add-on products.']
|
||||
continue
|
||||
|
||||
if v.valid_until is not None and v.valid_until < now_dt:
|
||||
errs[i]['voucher'] = [error_messages['voucher_expired']]
|
||||
continue
|
||||
if not v.applies_to(pos_data['item'], pos_data.get('variation')):
|
||||
errs[i]['voucher'] = [error_messages['voucher_invalid_item']]
|
||||
continue
|
||||
|
||||
voucher_usage[v] += 1
|
||||
if voucher_usage[v] > 0:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
|
||||
).exclude(pk__in=[cp.pk for cp in delete_cps])
|
||||
v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
|
||||
if v_avail < voucher_usage[v]:
|
||||
errs[i]['voucher'] = [
|
||||
'The voucher has already been used the maximum number of times.'
|
||||
]
|
||||
if v.subevent_id and pos_data.get('subevent').pk != v.subevent_id:
|
||||
errs[i]['voucher'] = [error_messages['voucher_invalid_subevent']]
|
||||
continue
|
||||
|
||||
if v.budget is not None:
|
||||
price = pos_data.get('price')
|
||||
listed_price = get_listed_price(pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent'))
|
||||
if v.valid_until is not None and v.valid_until < now_dt:
|
||||
errs[i]['voucher'] = [error_messages['voucher_expired']]
|
||||
continue
|
||||
|
||||
if pos_data.get('voucher'):
|
||||
price_after_voucher = pos_data.get('voucher').calculate_price(listed_price)
|
||||
else:
|
||||
price_after_voucher = listed_price
|
||||
if price is None:
|
||||
price = price_after_voucher
|
||||
voucher_usage[v] += 1
|
||||
if voucher_usage[v] > 0:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
|
||||
).exclude(pk__in=[cp.pk for cp in delete_cps])
|
||||
v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
|
||||
if v_avail < voucher_usage[v]:
|
||||
errs[i]['voucher'] = [
|
||||
'The voucher has already been used the maximum number of times.'
|
||||
]
|
||||
|
||||
if v not in v_budget:
|
||||
v_budget[v] = v.budget - v.budget_used()
|
||||
disc = max(listed_price - price, 0)
|
||||
if disc > v_budget[v]:
|
||||
new_disc = v_budget[v]
|
||||
v_budget[v] -= new_disc
|
||||
if new_disc == Decimal('0.00') or pos_data.get('price') is not None:
|
||||
errs[i]['voucher'] = [
|
||||
'The voucher has a remaining budget of {}, therefore a discount of {} can not be '
|
||||
'given.'.format(v_budget[v] + new_disc, disc)
|
||||
]
|
||||
continue
|
||||
pos_data['price'] = price + (disc - new_disc)
|
||||
else:
|
||||
v_budget[v] -= disc
|
||||
if v.budget is not None:
|
||||
price = pos_data.get('price')
|
||||
listed_price = get_listed_price(pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent'))
|
||||
|
||||
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
|
||||
if pos_data.get('seat'):
|
||||
if pos_data.get('addon_to'):
|
||||
errs[i]['seat'] = ['Seats are currently not supported for add-on products.']
|
||||
continue
|
||||
|
||||
if not seated:
|
||||
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
|
||||
try:
|
||||
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
errs[i]['seat'] = ['The specified seat does not exist.']
|
||||
else:
|
||||
pos_data['seat'] = seat
|
||||
if (seat not in free_seats and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat in seats_seen:
|
||||
errs[i]['seat'] = [gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
|
||||
seats_seen.add(seat)
|
||||
elif seated:
|
||||
errs[i]['seat'] = ['The specified product requires to choose a seat.']
|
||||
|
||||
requested_valid_from = pos_data.pop('requested_valid_from', None)
|
||||
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())
|
||||
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
|
||||
else now()
|
||||
),
|
||||
enforce_start_limit=True,
|
||||
override_tz=self.context['event'].timezone,
|
||||
)
|
||||
pos_data['valid_from'] = valid_from
|
||||
pos_data['valid_until'] = valid_until
|
||||
|
||||
if not force:
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
if pos_data.get('voucher'):
|
||||
if pos_data['voucher'].allow_ignore_quota or pos_data['voucher'].block_quota:
|
||||
price_after_voucher = pos_data.get('voucher').calculate_price(listed_price)
|
||||
else:
|
||||
price_after_voucher = listed_price
|
||||
if price is None:
|
||||
price = price_after_voucher
|
||||
|
||||
if v not in v_budget:
|
||||
v_budget[v] = v.budget - v.budget_used()
|
||||
disc = max(listed_price - price, 0)
|
||||
if disc > v_budget[v]:
|
||||
new_disc = v_budget[v]
|
||||
v_budget[v] -= new_disc
|
||||
if new_disc == Decimal('0.00') or pos_data.get('price') is not None:
|
||||
errs[i]['voucher'] = [
|
||||
'The voucher has a remaining budget of {}, therefore a discount of {} can not be '
|
||||
'given.'.format(v_budget[v] + new_disc, disc)
|
||||
]
|
||||
continue
|
||||
pos_data['price'] = price + (disc - new_disc)
|
||||
else:
|
||||
v_budget[v] -= disc
|
||||
|
||||
if pos_data.get('subevent'):
|
||||
if pos_data.get('item').pk in pos_data['subevent'].item_overrides and pos_data['subevent'].item_overrides[pos_data['item'].pk].disabled:
|
||||
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format(
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
if (
|
||||
pos_data.get('variation') and pos_data['variation'].pk in pos_data['subevent'].var_overrides and
|
||||
pos_data['subevent'].var_overrides[pos_data['variation'].pk].disabled
|
||||
):
|
||||
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format(
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
|
||||
if pos_data.get('seat'):
|
||||
if pos_data.get('addon_to'):
|
||||
errs[i]['seat'] = ['Seats are currently not supported for add-on products.']
|
||||
continue
|
||||
if not seated:
|
||||
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
|
||||
seat = pos_data['seat']
|
||||
if seat is Seat.DoesNotExist:
|
||||
errs[i]['seat'] = ['The specified seat does not exist.']
|
||||
else:
|
||||
seat_usage[seat] += 1
|
||||
if (seat_usage[seat] > 0 and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat_usage[seat] > 1:
|
||||
errs[i]['seat'] = [gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
|
||||
elif seated:
|
||||
errs[i]['seat'] = ['The specified product requires to choose a seat.']
|
||||
|
||||
new_quotas = pos_data['_quotas']
|
||||
if len(new_quotas) == 0:
|
||||
errs[i]['item'] = [gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
requested_valid_from = pos_data.pop('requested_valid_from', None)
|
||||
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())
|
||||
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
|
||||
else now()
|
||||
),
|
||||
enforce_start_limit=True,
|
||||
override_tz=self.context['event'].timezone,
|
||||
)
|
||||
pos_data['valid_from'] = valid_from
|
||||
pos_data['valid_until'] = valid_until
|
||||
|
||||
if not force:
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
if pos_data.get('voucher'):
|
||||
if pos_data['voucher'].allow_ignore_quota or pos_data['voucher'].block_quota:
|
||||
continue
|
||||
|
||||
if pos_data.get('subevent'):
|
||||
if pos_data.get('item').pk in pos_data['subevent'].item_overrides and pos_data['subevent'].item_overrides[pos_data['item'].pk].disabled:
|
||||
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format(
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
if (
|
||||
pos_data.get('variation') and pos_data['variation'].pk in pos_data['subevent'].var_overrides and
|
||||
pos_data['subevent'].var_overrides[pos_data['variation'].pk].disabled
|
||||
):
|
||||
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format(
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
else:
|
||||
for quota in new_quotas:
|
||||
if quota not in quota_avail_cache:
|
||||
quota_avail_cache[quota] = list(quota.availability())
|
||||
|
||||
if quota_avail_cache[quota][1] is not None:
|
||||
quota_avail_cache[quota][1] -= 1
|
||||
if quota_avail_cache[quota][1] < 0:
|
||||
errs[i]['item'] = [
|
||||
gettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
|
||||
quota.name
|
||||
)
|
||||
]
|
||||
|
||||
if any(errs):
|
||||
raise ValidationError({'positions': errs})
|
||||
|
||||
if validated_data.get('locale', None) is None:
|
||||
validated_data['locale'] = self.context['event'].settings.locale
|
||||
order = Order(event=self.context['event'], **validated_data)
|
||||
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
|
||||
order.meta_info = "{}"
|
||||
order.total = Decimal('0.00')
|
||||
if validated_data.get('require_approval') is not None:
|
||||
order.require_approval = validated_data['require_approval']
|
||||
if simulate:
|
||||
order = WrappedModel(order)
|
||||
order.last_modified = now()
|
||||
order.code = 'PREVIEW'
|
||||
else:
|
||||
order.save()
|
||||
|
||||
if ia:
|
||||
if not simulate:
|
||||
ia.order = order
|
||||
ia.save()
|
||||
new_quotas = quotas_by_item[pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent')]
|
||||
if len(new_quotas) == 0:
|
||||
errs[i]['item'] = [gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
else:
|
||||
order.invoice_address = ia
|
||||
ia.last_modified = now()
|
||||
for quota in new_quotas:
|
||||
quota_usage[quota] += 1
|
||||
if quota_usage[quota] > 0 and qa.results[quota][1] is not None:
|
||||
if qa.results[quota][1] < quota_usage[quota]:
|
||||
errs[i]['item'] = [
|
||||
gettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
|
||||
quota.name
|
||||
)
|
||||
]
|
||||
|
||||
# Generate position objects
|
||||
pos_map = {}
|
||||
for pos_data in positions_data:
|
||||
addon_to = pos_data.pop('addon_to', None)
|
||||
attendee_name = pos_data.pop('attendee_name', '')
|
||||
if attendee_name and not pos_data.get('attendee_name_parts'):
|
||||
pos_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
|
||||
if simulate:
|
||||
pos.order = order._wrapped
|
||||
else:
|
||||
pos.order = order
|
||||
if addon_to:
|
||||
if simulate:
|
||||
pos.addon_to = pos_map[addon_to]
|
||||
else:
|
||||
pos.addon_to = pos_map[addon_to]
|
||||
if any(errs):
|
||||
raise ValidationError({'positions': errs})
|
||||
|
||||
pos_map[pos.positionid] = pos
|
||||
pos_data['__instance'] = pos
|
||||
|
||||
# Calculate prices if not set
|
||||
for pos_data in positions_data:
|
||||
pos = pos_data['__instance']
|
||||
if pos.addon_to_id and is_included_for_free(pos.item, pos.addon_to):
|
||||
listed_price = Decimal('0.00')
|
||||
else:
|
||||
listed_price = get_listed_price(pos.item, pos.variation, pos.subevent)
|
||||
|
||||
if pos.price is None:
|
||||
if pos.voucher:
|
||||
price_after_voucher = pos.voucher.calculate_price(listed_price)
|
||||
else:
|
||||
price_after_voucher = listed_price
|
||||
|
||||
line_price = get_line_price(
|
||||
price_after_voucher=price_after_voucher,
|
||||
custom_price_input=None,
|
||||
custom_price_input_is_net=False,
|
||||
tax_rule=pos.item.tax_rule,
|
||||
invoice_address=ia,
|
||||
bundled_sum=Decimal('0.00'),
|
||||
)
|
||||
pos.price = line_price.gross
|
||||
pos._auto_generated_price = True
|
||||
else:
|
||||
if pos.voucher:
|
||||
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:
|
||||
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
|
||||
else:
|
||||
price_after_voucher = listed_price
|
||||
pos._auto_generated_price = False
|
||||
pos._voucher_discount = listed_price - price_after_voucher
|
||||
if pos.voucher:
|
||||
pos.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
|
||||
|
||||
order_positions = [pos_data['__instance'] for pos_data in positions_data]
|
||||
discount_results = apply_discounts(
|
||||
self.context['event'],
|
||||
order.sales_channel,
|
||||
[
|
||||
(cp.item_id, cp.subevent_id, cp.price, bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
|
||||
for cp in order_positions
|
||||
]
|
||||
)
|
||||
for cp, (new_price, discount) in zip(order_positions, discount_results):
|
||||
if new_price != pos.price and pos._auto_generated_price:
|
||||
pos.price = new_price
|
||||
pos.discount = discount
|
||||
|
||||
# Save instances
|
||||
for pos_data in positions_data:
|
||||
answers_data = pos_data.pop('answers', [])
|
||||
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
|
||||
pos = pos_data['__instance']
|
||||
pos._calculate_tax()
|
||||
|
||||
if simulate:
|
||||
pos = WrappedModel(pos)
|
||||
pos.id = 0
|
||||
answers = []
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options', [])
|
||||
answ = WrappedModel(QuestionAnswer(**answ_data))
|
||||
answ.options = WrappedList(options)
|
||||
answers.append(answ)
|
||||
pos.answers = answers
|
||||
pos.pseudonymization_id = "PREVIEW"
|
||||
pos_map[pos.positionid] = pos
|
||||
else:
|
||||
if pos.voucher:
|
||||
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
|
||||
pos.save()
|
||||
seen_answers = set()
|
||||
for answ_data in answers_data:
|
||||
# Workaround for a pretixPOS bug :-(
|
||||
if answ_data.get('question') in seen_answers:
|
||||
continue
|
||||
seen_answers.add(answ_data.get('question'))
|
||||
|
||||
options = answ_data.pop('options', [])
|
||||
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
answ = pos.answers.create(**answ_data, answer='')
|
||||
answ.file.save(os.path.basename(an.name), an, save=False)
|
||||
answ.answer = 'file://' + answ.file.name
|
||||
answ.save()
|
||||
else:
|
||||
answ = pos.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
|
||||
if use_reusable_medium:
|
||||
use_reusable_medium.linked_orderposition = pos
|
||||
use_reusable_medium.save(update_fields=['linked_orderposition'])
|
||||
use_reusable_medium.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.changed',
|
||||
data={
|
||||
'by_order': order.code,
|
||||
'linked_orderposition': pos.pk,
|
||||
}
|
||||
)
|
||||
if validated_data.get('locale', None) is None:
|
||||
validated_data['locale'] = self.context['event'].settings.locale
|
||||
order = Order(event=self.context['event'], **validated_data)
|
||||
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
|
||||
order.meta_info = "{}"
|
||||
order.total = Decimal('0.00')
|
||||
if validated_data.get('require_approval') is not None:
|
||||
order.require_approval = validated_data['require_approval']
|
||||
if simulate:
|
||||
order = WrappedModel(order)
|
||||
order.last_modified = now()
|
||||
order.code = 'PREVIEW'
|
||||
else:
|
||||
order.save()
|
||||
|
||||
if ia:
|
||||
if not simulate:
|
||||
for cp in delete_cps:
|
||||
if cp.addon_to_id:
|
||||
ia.order = order
|
||||
ia.save()
|
||||
else:
|
||||
order.invoice_address = ia
|
||||
ia.last_modified = now()
|
||||
|
||||
# Generate position objects
|
||||
pos_map = {}
|
||||
for pos_data in positions_data:
|
||||
addon_to = pos_data.pop('addon_to', None)
|
||||
attendee_name = pos_data.pop('attendee_name', '')
|
||||
if attendee_name and not pos_data.get('attendee_name_parts'):
|
||||
pos_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
|
||||
if simulate:
|
||||
pos.order = order._wrapped
|
||||
else:
|
||||
pos.order = order
|
||||
if addon_to:
|
||||
if simulate:
|
||||
pos.addon_to = pos_map[addon_to]
|
||||
else:
|
||||
pos.addon_to = pos_map[addon_to]
|
||||
|
||||
pos_map[pos.positionid] = pos
|
||||
pos_data['__instance'] = pos
|
||||
|
||||
# Calculate prices if not set
|
||||
for pos_data in positions_data:
|
||||
pos = pos_data['__instance']
|
||||
if pos.addon_to_id and is_included_for_free(pos.item, pos.addon_to):
|
||||
listed_price = Decimal('0.00')
|
||||
else:
|
||||
listed_price = get_listed_price(pos.item, pos.variation, pos.subevent)
|
||||
|
||||
if pos.price is None:
|
||||
if pos.voucher:
|
||||
price_after_voucher = pos.voucher.calculate_price(listed_price)
|
||||
else:
|
||||
price_after_voucher = listed_price
|
||||
|
||||
line_price = get_line_price(
|
||||
price_after_voucher=price_after_voucher,
|
||||
custom_price_input=None,
|
||||
custom_price_input_is_net=False,
|
||||
tax_rule=pos.item.tax_rule,
|
||||
invoice_address=ia,
|
||||
bundled_sum=Decimal('0.00'),
|
||||
)
|
||||
pos.price = line_price.gross
|
||||
pos._auto_generated_price = True
|
||||
else:
|
||||
if pos.voucher:
|
||||
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:
|
||||
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
|
||||
else:
|
||||
price_after_voucher = listed_price
|
||||
pos._auto_generated_price = False
|
||||
pos._voucher_discount = listed_price - price_after_voucher
|
||||
if pos.voucher:
|
||||
pos.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
|
||||
|
||||
order_positions = [pos_data['__instance'] for pos_data in positions_data]
|
||||
discount_results = apply_discounts(
|
||||
self.context['event'],
|
||||
order.sales_channel,
|
||||
[
|
||||
(cp.item_id, cp.subevent_id, cp.price, bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
|
||||
for cp in order_positions
|
||||
]
|
||||
)
|
||||
for cp, (new_price, discount) in zip(order_positions, discount_results):
|
||||
if new_price != pos.price and pos._auto_generated_price:
|
||||
pos.price = new_price
|
||||
pos.discount = discount
|
||||
|
||||
# Save instances
|
||||
for pos_data in positions_data:
|
||||
answers_data = pos_data.pop('answers', [])
|
||||
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
|
||||
pos = pos_data['__instance']
|
||||
pos._calculate_tax()
|
||||
|
||||
if simulate:
|
||||
pos = WrappedModel(pos)
|
||||
pos.id = 0
|
||||
answers = []
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options', [])
|
||||
answ = WrappedModel(QuestionAnswer(**answ_data))
|
||||
answ.options = WrappedList(options)
|
||||
answers.append(answ)
|
||||
pos.answers = answers
|
||||
pos.pseudonymization_id = "PREVIEW"
|
||||
pos.checkins = []
|
||||
pos_map[pos.positionid] = pos
|
||||
else:
|
||||
if pos.voucher:
|
||||
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
|
||||
pos.save()
|
||||
seen_answers = set()
|
||||
for answ_data in answers_data:
|
||||
# Workaround for a pretixPOS bug :-(
|
||||
if answ_data.get('question') in seen_answers:
|
||||
continue
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
seen_answers.add(answ_data.get('question'))
|
||||
|
||||
options = answ_data.pop('options', [])
|
||||
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
answ = pos.answers.create(**answ_data, answer='')
|
||||
answ.file.save(os.path.basename(an.name), an, save=False)
|
||||
answ.answer = 'file://' + answ.file.name
|
||||
answ.save()
|
||||
else:
|
||||
answ = pos.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
|
||||
if use_reusable_medium:
|
||||
use_reusable_medium.linked_orderposition = pos
|
||||
use_reusable_medium.save(update_fields=['linked_orderposition'])
|
||||
use_reusable_medium.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.changed',
|
||||
data={
|
||||
'by_order': order.code,
|
||||
'linked_orderposition': pos.pk,
|
||||
}
|
||||
)
|
||||
|
||||
if not simulate:
|
||||
for cp in delete_cps:
|
||||
if cp.addon_to_id:
|
||||
continue
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
|
||||
order.total = sum([p.price for p in pos_map.values()])
|
||||
fees = []
|
||||
@@ -1459,6 +1562,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if simulate:
|
||||
order.fees = fees
|
||||
order.positions = pos_map.values()
|
||||
order.payments = []
|
||||
order.refunds = []
|
||||
return order # ignore payments
|
||||
else:
|
||||
order.save(update_fields=['total'])
|
||||
@@ -1521,6 +1626,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
||||
|
||||
|
||||
class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
event = SlugRelatedField(slug_field='slug', read_only=True)
|
||||
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
|
||||
refers = serializers.SlugRelatedField(slug_field='full_invoice_no', read_only=True)
|
||||
lines = InlineInvoiceLineSerializer(many=True)
|
||||
@@ -1529,7 +1635,7 @@ class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Invoice
|
||||
fields = ('order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
|
||||
fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
|
||||
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
|
||||
'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode',
|
||||
'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary',
|
||||
|
||||
@@ -70,6 +70,8 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
if 'order' in self.context:
|
||||
data['order'] = self.context['order']
|
||||
if data.get('addon_to'):
|
||||
try:
|
||||
data['addon_to'] = data['order'].positions.get(positionid=data['addon_to'])
|
||||
|
||||
@@ -36,9 +36,9 @@ from pretix.api.serializers.settings import SettingsSerializer
|
||||
from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.i18n import get_language_without_region
|
||||
from pretix.base.models import (
|
||||
Customer, Device, GiftCard, GiftCardTransaction, Membership,
|
||||
MembershipType, OrderPosition, Organizer, ReusableMedium, SeatingPlan,
|
||||
Team, TeamAPIToken, TeamInvite, User,
|
||||
Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction,
|
||||
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
|
||||
SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
@@ -94,6 +94,14 @@ class CustomerSerializer(I18nAwareModelSerializer):
|
||||
data['name_parts']['_scheme'] = self.context['request'].organizer.settings.name_scheme
|
||||
return data
|
||||
|
||||
def validate_email(self, value):
|
||||
qs = Customer.objects.filter(organizer=self.context['organizer'], email__iexact=value)
|
||||
if self.instance and self.instance.pk:
|
||||
qs = qs.exclude(pk=self.instance.pk)
|
||||
if qs.exists():
|
||||
raise ValidationError(_("An account with this email address is already registered."))
|
||||
return value
|
||||
|
||||
|
||||
class CustomerCreateSerializer(CustomerSerializer):
|
||||
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
|
||||
@@ -183,8 +191,11 @@ class GiftCardSerializer(I18nAwareModelSerializer):
|
||||
qs = GiftCard.objects.filter(
|
||||
secret=s
|
||||
).filter(
|
||||
Q(issuer=self.context["organizer"]) | Q(
|
||||
issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
|
||||
Q(issuer=self.context["organizer"]) |
|
||||
Q(issuer__in=GiftCardAcceptance.objects.filter(
|
||||
acceptor=self.context["organizer"],
|
||||
active=True,
|
||||
).values_list('issuer', flat=True))
|
||||
)
|
||||
if self.instance:
|
||||
qs = qs.exclude(pk=self.instance.pk)
|
||||
@@ -248,6 +259,8 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
unique_serial = serializers.CharField(read_only=True)
|
||||
hardware_brand = serializers.CharField(read_only=True)
|
||||
hardware_model = serializers.CharField(read_only=True)
|
||||
os_name = serializers.CharField(read_only=True)
|
||||
os_version = serializers.CharField(read_only=True)
|
||||
software_brand = serializers.CharField(read_only=True)
|
||||
software_version = serializers.CharField(read_only=True)
|
||||
created = serializers.DateTimeField(read_only=True)
|
||||
@@ -260,7 +273,7 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
fields = (
|
||||
'device_id', 'unique_serial', 'initialization_token', 'all_events', 'limit_events',
|
||||
'revoked', 'name', 'created', 'initialized', 'hardware_brand', 'hardware_model',
|
||||
'software_brand', 'software_version', 'security_profile'
|
||||
'os_name', 'os_version', 'software_brand', 'software_version', 'security_profile'
|
||||
)
|
||||
|
||||
|
||||
@@ -387,6 +400,9 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
'reusable_media_type_nfc_uid',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
|
||||
'reusable_media_type_nfc_mf0aes',
|
||||
'reusable_media_type_nfc_mf0aes_autocreate_giftcard',
|
||||
'reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -24,10 +24,16 @@ from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import Seat, Voucher
|
||||
from pretix.base.models.vouchers import generate_codes
|
||||
|
||||
|
||||
class VoucherListSerializer(serializers.ListSerializer):
|
||||
def create(self, validated_data):
|
||||
vouchers_without_codes = [v for v in validated_data if not v.get('code')]
|
||||
|
||||
for voucher_data, code in zip(vouchers_without_codes, generate_codes(self.context['event'].organizer, num=len(vouchers_without_codes), prefix=None)):
|
||||
voucher_data['code'] = code
|
||||
|
||||
codes = set()
|
||||
seats = set()
|
||||
errs = []
|
||||
@@ -94,8 +100,13 @@ class VoucherSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
if check_quota:
|
||||
Voucher.clean_quota_check(
|
||||
full_data, 1, self.instance, self.context.get('event'),
|
||||
full_data.get('quota'), full_data.get('item'), full_data.get('variation')
|
||||
full_data,
|
||||
full_data.get('max_usages', 1) - (self.instance.redeemed if self.instance else 0),
|
||||
self.instance,
|
||||
self.context.get('event'),
|
||||
full_data.get('quota'),
|
||||
full_data.get('item'),
|
||||
full_data.get('variation')
|
||||
)
|
||||
Voucher.clean_voucher_code(full_data, self.context.get('event'), self.instance.pk if self.instance else None)
|
||||
|
||||
|
||||
@@ -35,8 +35,7 @@
|
||||
import importlib
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf.urls import re_path
|
||||
from django.urls import include
|
||||
from django.urls import include, re_path
|
||||
from rest_framework import routers
|
||||
|
||||
from pretix.api.views import cart
|
||||
@@ -62,6 +61,8 @@ orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet)
|
||||
orga_router.register(r'reusablemedia', media.ReusableMediaViewSet)
|
||||
orga_router.register(r'teams', organizer.TeamViewSet)
|
||||
orga_router.register(r'devices', organizer.DeviceViewSet)
|
||||
orga_router.register(r'orders', order.OrganizerOrderViewSet)
|
||||
orga_router.register(r'invoices', order.InvoiceViewSet)
|
||||
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
|
||||
|
||||
team_router = routers.DefaultRouter()
|
||||
@@ -78,7 +79,7 @@ event_router.register(r'questions', item.QuestionViewSet)
|
||||
event_router.register(r'discounts', discount.DiscountViewSet)
|
||||
event_router.register(r'quotas', item.QuotaViewSet)
|
||||
event_router.register(r'vouchers', voucher.VoucherViewSet)
|
||||
event_router.register(r'orders', order.OrderViewSet)
|
||||
event_router.register(r'orders', order.EventOrderViewSet)
|
||||
event_router.register(r'orderpositions', order.OrderPositionViewSet)
|
||||
event_router.register(r'invoices', order.InvoiceViewSet)
|
||||
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
|
||||
|
||||
@@ -25,6 +25,7 @@ from typing import List
|
||||
from django.db import transaction
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
@@ -41,7 +42,7 @@ from pretix.base.models import CartPosition
|
||||
from pretix.base.services.cart import (
|
||||
_get_quota_availability, _get_voucher_availability, error_messages,
|
||||
)
|
||||
from pretix.base.services.locking import NoLockManager
|
||||
from pretix.base.services.locking import lock_objects
|
||||
|
||||
|
||||
class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
@@ -150,12 +151,21 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
|
||||
quota_diff[q] += 1
|
||||
|
||||
seats_seen = set()
|
||||
now_dt = now()
|
||||
with transaction.atomic():
|
||||
full_lock_required = seat_diff and self.request.event.settings.seating_minimal_distance > 0
|
||||
if full_lock_required:
|
||||
# We lock the entire event in this case since we don't want to deal with fine-granular locking
|
||||
# in the case of seating distance enforcement
|
||||
lock_objects([self.request.event])
|
||||
else:
|
||||
lock_objects(
|
||||
[q for q, d in quota_diff.items() if q.size is not None and d > 0] +
|
||||
[v for v, d in voucher_use_diff.items() if d > 0] +
|
||||
[s for s, d in seat_diff.items() if d > 0],
|
||||
shared_lock_objects=[self.request.event]
|
||||
)
|
||||
|
||||
lockfn = NoLockManager
|
||||
if self._require_locking(quota_diff, voucher_use_diff, seat_diff):
|
||||
lockfn = self.request.event.lock
|
||||
|
||||
with lockfn() as now_dt, transaction.atomic():
|
||||
vouchers_ok, vouchers_depend_on_cart = _get_voucher_availability(
|
||||
self.request.event,
|
||||
voucher_use_diff,
|
||||
|
||||
@@ -164,8 +164,21 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
secret=serializer.validated_data['raw_barcode']
|
||||
).first()
|
||||
|
||||
clist = self.get_object()
|
||||
if serializer.validated_data.get('nonce'):
|
||||
if kwargs.get('position'):
|
||||
prev = kwargs['position'].all_checkins.filter(nonce=serializer.validated_data['nonce']).first()
|
||||
else:
|
||||
prev = clist.checkins.filter(
|
||||
nonce=serializer.validated_data['nonce'],
|
||||
raw_barcode=serializer.validated_data['raw_barcode'],
|
||||
).first()
|
||||
if prev:
|
||||
# Ignore because nonce is already handled
|
||||
return Response(serializer.data, status=201)
|
||||
|
||||
c = serializer.save(
|
||||
list=self.get_object(),
|
||||
list=clist,
|
||||
successful=False,
|
||||
forced=True,
|
||||
force_sent=True,
|
||||
@@ -265,6 +278,7 @@ with scopes_disabled():
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.checkinlist = kwargs.pop('checkinlist')
|
||||
self.gate = kwargs.pop('gate')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
@@ -274,7 +288,7 @@ with scopes_disabled():
|
||||
if not self.checkinlist.rules:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
SQLLogic(self.checkinlist).apply(self.checkinlist.rules)
|
||||
SQLLogic(self.checkinlist, self.gate).apply(self.checkinlist.rules)
|
||||
).filter(
|
||||
Q(valid_from__isnull=True) | Q(valid_from__lte=now()),
|
||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now()),
|
||||
@@ -396,7 +410,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
||||
|
||||
def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce,
|
||||
untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported,
|
||||
source_type='barcode', legacy_url_support=False):
|
||||
source_type='barcode', legacy_url_support=False, simulate=False, gate=None):
|
||||
if not checkinlists:
|
||||
raise ValidationError('No check-in list passed.')
|
||||
|
||||
@@ -404,7 +418,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
prefetch_related_objects([cl for cl in checkinlists if not cl.all_products], 'limit_products')
|
||||
|
||||
device = auth if isinstance(auth, Device) else None
|
||||
gate = auth.gate if isinstance(auth, Device) else None
|
||||
gate = gate or (auth.gate if isinstance(auth, Device) else None)
|
||||
|
||||
context = {
|
||||
'request': request,
|
||||
@@ -433,6 +447,8 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
)
|
||||
raw_barcode_for_checkin = None
|
||||
from_revoked_secret = False
|
||||
if simulate:
|
||||
common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True
|
||||
|
||||
# 1. Gather a list of positions that could be the one we looking for, either from their ID, secret or
|
||||
# parent secret
|
||||
@@ -472,13 +488,14 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
revoked_matches = list(
|
||||
RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode))
|
||||
if len(revoked_matches) == 0:
|
||||
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)
|
||||
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)
|
||||
|
||||
for cl in checkinlists:
|
||||
for k, s in cl.event.ticket_secret_generators.items():
|
||||
@@ -492,12 +509,13 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
except:
|
||||
pass
|
||||
|
||||
Checkin.objects.create(
|
||||
position=None,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_INVALID,
|
||||
**common_checkin_args,
|
||||
)
|
||||
if not simulate:
|
||||
Checkin.objects.create(
|
||||
position=None,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_INVALID,
|
||||
**common_checkin_args,
|
||||
)
|
||||
|
||||
if force and legacy_url_support and isinstance(auth, Device):
|
||||
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
|
||||
@@ -539,19 +557,20 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
from_revoked_secret = True
|
||||
else:
|
||||
op = revoked_matches[0].position
|
||||
op.order.log_action('pretix.event.checkin.revoked', data={
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': list_by_event[revoked_matches[0].event_id].pk,
|
||||
'barcode': raw_barcode
|
||||
}, user=user, auth=auth)
|
||||
common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id]
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_REVOKED,
|
||||
**common_checkin_args
|
||||
)
|
||||
if not simulate:
|
||||
op.order.log_action('pretix.event.checkin.revoked', data={
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': list_by_event[revoked_matches[0].event_id].pk,
|
||||
'barcode': raw_barcode
|
||||
}, user=user, auth=auth)
|
||||
common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id]
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_REVOKED,
|
||||
**common_checkin_args
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_REVOKED,
|
||||
@@ -588,24 +607,25 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
# We choose the first match (regardless of product) for the logging since it's most likely to be the
|
||||
# base product according to our order_by above.
|
||||
op = op_candidates[0]
|
||||
op.order.log_action('pretix.event.checkin.denied', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'errorcode': Checkin.REASON_AMBIGUOUS,
|
||||
'reason_explanation': None,
|
||||
'force': force,
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': list_by_event[op.order.event_id].pk,
|
||||
}, user=user, auth=auth)
|
||||
common_checkin_args['list'] = list_by_event[op.order.event_id]
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_AMBIGUOUS,
|
||||
error_explanation=None,
|
||||
**common_checkin_args,
|
||||
)
|
||||
if not simulate:
|
||||
op.order.log_action('pretix.event.checkin.denied', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'errorcode': Checkin.REASON_AMBIGUOUS,
|
||||
'reason_explanation': None,
|
||||
'force': force,
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': list_by_event[op.order.event_id].pk,
|
||||
}, user=user, auth=auth)
|
||||
common_checkin_args['list'] = list_by_event[op.order.event_id]
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_AMBIGUOUS,
|
||||
error_explanation=None,
|
||||
**common_checkin_args,
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_AMBIGUOUS,
|
||||
@@ -652,6 +672,8 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
raw_barcode=raw_barcode_for_checkin,
|
||||
raw_source_type=source_type,
|
||||
from_revoked_secret=from_revoked_secret,
|
||||
simulate=simulate,
|
||||
gate=gate,
|
||||
)
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
@@ -664,23 +686,24 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||
}, status=400)
|
||||
except CheckInError as e:
|
||||
op.order.log_action('pretix.event.checkin.denied', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'errorcode': e.code,
|
||||
'reason_explanation': e.reason,
|
||||
'force': force,
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': list_by_event[op.order.event_id].pk,
|
||||
}, user=user, auth=auth)
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=e.code,
|
||||
error_explanation=e.reason,
|
||||
**common_checkin_args,
|
||||
)
|
||||
if not simulate:
|
||||
op.order.log_action('pretix.event.checkin.denied', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'errorcode': e.code,
|
||||
'reason_explanation': e.reason,
|
||||
'force': force,
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': list_by_event[op.order.event_id].pk,
|
||||
}, user=user, auth=auth)
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=e.code,
|
||||
error_explanation=e.reason,
|
||||
**common_checkin_args,
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': e.code,
|
||||
@@ -749,6 +772,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
def get_filterset_kwargs(self):
|
||||
return {
|
||||
'checkinlist': self.checkinlist,
|
||||
'gate': self.request.auth.gate if isinstance(self.request.auth, Device) else None,
|
||||
}
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -19,8 +19,12 @@
|
||||
# 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 base64
|
||||
import logging
|
||||
|
||||
from cryptography.hazmat.backends.openssl.backend import Backend
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.timezone import now
|
||||
@@ -34,6 +38,8 @@ from pretix.api.auth.device import DeviceTokenAuthentication
|
||||
from pretix.api.views.version import numeric_version
|
||||
from pretix.base.models import CheckinList, Device, SubEvent
|
||||
from pretix.base.models.devices import Gate, generate_api_token
|
||||
from pretix.base.models.media import MediumKeySet
|
||||
from pretix.base.services.media import get_keysets_for_organizer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,17 +48,73 @@ class InitializationRequestSerializer(serializers.Serializer):
|
||||
token = serializers.CharField(max_length=190)
|
||||
hardware_brand = serializers.CharField(max_length=190)
|
||||
hardware_model = serializers.CharField(max_length=190)
|
||||
os_name = serializers.CharField(max_length=190, required=False, allow_null=True)
|
||||
os_version = serializers.CharField(max_length=190, required=False, allow_null=True)
|
||||
software_brand = serializers.CharField(max_length=190)
|
||||
software_version = serializers.CharField(max_length=190)
|
||||
info = serializers.JSONField(required=False, allow_null=True)
|
||||
rsa_pubkey = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs.get('rsa_pubkey'):
|
||||
try:
|
||||
load_pem_public_key(
|
||||
attrs['rsa_pubkey'].encode(), Backend()
|
||||
)
|
||||
except:
|
||||
raise ValidationError({'rsa_pubkey': ['Not a valid public key.']})
|
||||
return attrs
|
||||
|
||||
|
||||
class UpdateRequestSerializer(serializers.Serializer):
|
||||
hardware_brand = serializers.CharField(max_length=190)
|
||||
hardware_model = serializers.CharField(max_length=190)
|
||||
os_name = serializers.CharField(max_length=190, required=False, allow_null=True)
|
||||
os_version = serializers.CharField(max_length=190, required=False, allow_null=True)
|
||||
software_brand = serializers.CharField(max_length=190)
|
||||
software_version = serializers.CharField(max_length=190)
|
||||
info = serializers.JSONField(required=False, allow_null=True)
|
||||
rsa_pubkey = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs.get('rsa_pubkey'):
|
||||
try:
|
||||
load_pem_public_key(
|
||||
attrs['rsa_pubkey'].encode(), Backend()
|
||||
)
|
||||
except:
|
||||
raise ValidationError({'rsa_pubkey': ['Not a valid public key.']})
|
||||
return attrs
|
||||
|
||||
|
||||
class RSAEncryptedField(serializers.Field):
|
||||
def to_representation(self, value):
|
||||
public_key = load_pem_public_key(
|
||||
self.context['device'].rsa_pubkey.encode(), Backend()
|
||||
)
|
||||
cipher_text = public_key.encrypt(
|
||||
# RSA/ECB/PKCS1Padding
|
||||
value,
|
||||
padding.PKCS1v15()
|
||||
)
|
||||
return base64.b64encode(cipher_text).decode()
|
||||
|
||||
|
||||
class MediumKeySetSerializer(serializers.ModelSerializer):
|
||||
uid_key = RSAEncryptedField(read_only=True)
|
||||
diversification_key = RSAEncryptedField(read_only=True)
|
||||
organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = MediumKeySet
|
||||
fields = [
|
||||
'public_id',
|
||||
'organizer',
|
||||
'active',
|
||||
'media_type',
|
||||
'uid_key',
|
||||
'diversification_key',
|
||||
]
|
||||
|
||||
|
||||
class GateSerializer(serializers.ModelSerializer):
|
||||
@@ -93,12 +155,18 @@ class InitializeView(APIView):
|
||||
if device.initialized:
|
||||
raise ValidationError({'token': ['This initialization token has already been used.']})
|
||||
|
||||
if device.revoked:
|
||||
raise ValidationError({'token': ['This initialization token has been revoked.']})
|
||||
|
||||
device.initialized = now()
|
||||
device.hardware_brand = serializer.validated_data.get('hardware_brand')
|
||||
device.hardware_model = serializer.validated_data.get('hardware_model')
|
||||
device.os_name = serializer.validated_data.get('os_name')
|
||||
device.os_version = serializer.validated_data.get('os_version')
|
||||
device.software_brand = serializer.validated_data.get('software_brand')
|
||||
device.software_version = serializer.validated_data.get('software_version')
|
||||
device.info = serializer.validated_data.get('info')
|
||||
device.rsa_pubkey = serializer.validated_data.get('rsa_pubkey')
|
||||
device.api_token = generate_api_token()
|
||||
device.save()
|
||||
|
||||
@@ -117,8 +185,15 @@ class UpdateView(APIView):
|
||||
device = request.auth
|
||||
device.hardware_brand = serializer.validated_data.get('hardware_brand')
|
||||
device.hardware_model = serializer.validated_data.get('hardware_model')
|
||||
device.os_name = serializer.validated_data.get('os_name')
|
||||
device.os_version = serializer.validated_data.get('os_version')
|
||||
device.software_brand = serializer.validated_data.get('software_brand')
|
||||
device.software_version = serializer.validated_data.get('software_version')
|
||||
if serializer.validated_data.get('rsa_pubkey') and serializer.validated_data.get('rsa_pubkey') != device.rsa_pubkey:
|
||||
if device.rsa_pubkey:
|
||||
raise ValidationError({'rsa_pubkey': ['You cannot change the rsa_pubkey of the device once it is set.']})
|
||||
else:
|
||||
device.rsa_pubkey = serializer.validated_data.get('rsa_pubkey')
|
||||
device.info = serializer.validated_data.get('info')
|
||||
device.save()
|
||||
device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device)
|
||||
@@ -166,8 +241,12 @@ class InfoView(APIView):
|
||||
'pretix': __version__,
|
||||
'pretix_numeric': numeric_version(__version__),
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
'medium_key_sets': MediumKeySetSerializer(
|
||||
get_keysets_for_organizer(device.organizer),
|
||||
many=True,
|
||||
context={'device': request.auth}
|
||||
).data if device.rsa_pubkey else []
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ with scopes_disabled():
|
||||
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
|
||||
sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs')
|
||||
search = django_filters.rest_framework.CharFilter(method='search_qs')
|
||||
date_from = django_filters.rest_framework.IsoDateTimeFromToRangeFilter()
|
||||
date_to = django_filters.rest_framework.IsoDateTimeFromToRangeFilter()
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
@@ -336,6 +338,8 @@ with scopes_disabled():
|
||||
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
|
||||
sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs')
|
||||
search = django_filters.rest_framework.CharFilter(method='search_qs')
|
||||
date_from = django_filters.rest_framework.IsoDateTimeFromToRangeFilter()
|
||||
date_to = django_filters.rest_framework.IsoDateTimeFromToRangeFilter()
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
@@ -377,16 +381,29 @@ with scopes_disabled():
|
||||
| Q(location__icontains=i18ncomp(value))
|
||||
)
|
||||
|
||||
class OrganizerSubEventFilter(SubEventFilter):
|
||||
def search_qs(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
Q(name__icontains=i18ncomp(value))
|
||||
| Q(event__slug__icontains=value)
|
||||
| Q(location__icontains=i18ncomp(value))
|
||||
)
|
||||
|
||||
|
||||
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = SubEventSerializer
|
||||
queryset = SubEvent.objects.none()
|
||||
write_permission = 'can_change_event_settings'
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
filterset_class = SubEventFilter
|
||||
ordering = ('date_from',)
|
||||
ordering_fields = ('id', 'date_from', 'last_modified')
|
||||
|
||||
@property
|
||||
def filterset_class(self):
|
||||
if getattr(self.request, 'event', None):
|
||||
return SubEventFilter
|
||||
return OrganizerSubEventFilter
|
||||
|
||||
def get_queryset(self):
|
||||
if getattr(self.request, 'event', None):
|
||||
qs = self.request.event.subevents
|
||||
@@ -411,6 +428,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
'subeventitem_set',
|
||||
'subeventitemvariation_set',
|
||||
'meta_values',
|
||||
'meta_values__property',
|
||||
Prefetch(
|
||||
'seat_category_mappings',
|
||||
to_attr='_seat_category_mappings',
|
||||
|
||||
@@ -133,7 +133,12 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
def exporters(self):
|
||||
exporters = []
|
||||
responses = register_data_exporters.send(self.request.event)
|
||||
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
|
||||
raw_exporters = [response(self.request.event, self.request.organizer) for r, response in responses if response]
|
||||
raw_exporters = [
|
||||
ex for ex in raw_exporters
|
||||
if ex.available_for_user(self.request.user if self.request.user and self.request.user.is_authenticated else None)
|
||||
]
|
||||
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
|
||||
ex._serializer = JobRunSerializer(exporter=ex)
|
||||
exporters.append(ex)
|
||||
return exporters
|
||||
@@ -166,7 +171,7 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
if (
|
||||
not isinstance(ex, OrganizerLevelExportMixin) or
|
||||
perm_holder.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request)
|
||||
)
|
||||
) and ex.available_for_user(self.request.user if self.request.user and self.request.user.is_authenticated else None)
|
||||
]
|
||||
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
|
||||
ex._serializer = JobRunSerializer(exporter=ex, events=events)
|
||||
|
||||
@@ -39,7 +39,8 @@ from pretix.api.serializers.media import (
|
||||
)
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import (
|
||||
Checkin, GiftCard, GiftCardTransaction, OrderPosition, ReusableMedium,
|
||||
Checkin, GiftCard, GiftCardAcceptance, GiftCardTransaction, OrderPosition,
|
||||
ReusableMedium,
|
||||
)
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
@@ -103,6 +104,12 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
||||
)
|
||||
mt = MEDIA_TYPES.get(serializer.validated_data["type"])
|
||||
if mt:
|
||||
m = mt.handle_new(self.request.organizer, inst, self.request.user, self.request.auth)
|
||||
if m:
|
||||
s = self.get_serializer(m)
|
||||
return Response({"result": s.data})
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
@@ -135,12 +142,28 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
s = self.get_serializer(m)
|
||||
return Response({"result": s.data})
|
||||
except ReusableMedium.DoesNotExist:
|
||||
mt = MEDIA_TYPES.get(s.validated_data["type"])
|
||||
if mt:
|
||||
m = mt.handle_unknown(request.organizer, s.validated_data["identifier"], request.user, request.auth)
|
||||
if m:
|
||||
s = self.get_serializer(m)
|
||||
return Response({"result": s.data})
|
||||
try:
|
||||
with scopes_disabled():
|
||||
m = ReusableMedium.objects.get(
|
||||
organizer__in=GiftCardAcceptance.objects.filter(
|
||||
acceptor=request.organizer,
|
||||
active=True,
|
||||
reusable_media=True,
|
||||
).values_list('issuer', flat=True),
|
||||
type=s.validated_data["type"],
|
||||
identifier=s.validated_data["identifier"],
|
||||
)
|
||||
m.linked_orderposition = None # not relevant for cross-organizer
|
||||
m.customer = None # not relevant for cross-organizer
|
||||
s = self.get_serializer(m)
|
||||
return Response({"result": s.data})
|
||||
except ReusableMedium.DoesNotExist:
|
||||
mt = MEDIA_TYPES.get(s.validated_data["type"])
|
||||
if mt:
|
||||
m = mt.handle_unknown(request.organizer, s.validated_data["identifier"], request.user, request.auth)
|
||||
if m:
|
||||
s = self.get_serializer(m)
|
||||
return Response({"result": s.data})
|
||||
|
||||
return Response({"result": None})
|
||||
|
||||
|
||||
@@ -23,9 +23,10 @@ import datetime
|
||||
import mimetypes
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import django_filters
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Exists, F, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects,
|
||||
@@ -44,6 +45,7 @@ from rest_framework.exceptions import (
|
||||
APIException, NotFound, PermissionDenied, ValidationError,
|
||||
)
|
||||
from rest_framework.mixins import CreateModelMixin
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
@@ -108,19 +110,24 @@ with scopes_disabled():
|
||||
item = django_filters.CharFilter(field_name='all_positions', lookup_expr='item_id', distinct=True)
|
||||
variation = django_filters.CharFilter(field_name='all_positions', lookup_expr='variation_id', distinct=True)
|
||||
subevent = django_filters.CharFilter(field_name='all_positions', lookup_expr='subevent_id', distinct=True)
|
||||
customer = django_filters.CharFilter(field_name='customer__identifier')
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
|
||||
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval', 'customer']
|
||||
|
||||
@scopes_disabled()
|
||||
def subevent_after_qs(self, qs, name, value):
|
||||
if getattr(self.request, 'event', None):
|
||||
subevents = self.request.event.subevents
|
||||
else:
|
||||
subevents = SubEvent.objects.filter(event__organizer=self.request.organizer)
|
||||
|
||||
qs = qs.filter(
|
||||
pk__in=Subquery(
|
||||
OrderPosition.all.filter(
|
||||
subevent_id__in=SubEvent.objects.filter(
|
||||
subevent_id__in=subevents.filter(
|
||||
Q(date_to__gt=value) | Q(date_from__gt=value, date_to__isnull=True),
|
||||
event=self.request.event
|
||||
).values_list('id'),
|
||||
).values_list('order_id')
|
||||
)
|
||||
@@ -128,12 +135,16 @@ with scopes_disabled():
|
||||
return qs
|
||||
|
||||
def subevent_before_qs(self, qs, name, value):
|
||||
if getattr(self.request, 'event', None):
|
||||
subevents = self.request.event.subevents
|
||||
else:
|
||||
subevents = SubEvent.objects.filter(event__organizer=self.request.organizer)
|
||||
|
||||
qs = qs.filter(
|
||||
pk__in=Subquery(
|
||||
OrderPosition.all.filter(
|
||||
subevent_id__in=SubEvent.objects.filter(
|
||||
subevent_id__in=subevents.filter(
|
||||
Q(date_from__lt=value),
|
||||
event=self.request.event
|
||||
).values_list('id'),
|
||||
).values_list('order_id')
|
||||
)
|
||||
@@ -185,7 +196,7 @@ with scopes_disabled():
|
||||
)
|
||||
|
||||
|
||||
class OrderViewSet(viewsets.ModelViewSet):
|
||||
class OrderViewSetMixin:
|
||||
serializer_class = OrderSerializer
|
||||
queryset = Order.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
@@ -193,19 +204,12 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
ordering_fields = ('datetime', 'code', 'status', 'last_modified')
|
||||
filterset_class = OrderFilter
|
||||
lookup_field = 'code'
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
|
||||
ctx['exclude'] = self.request.query_params.getlist('exclude')
|
||||
ctx['include'] = self.request.query_params.getlist('include')
|
||||
return ctx
|
||||
def get_base_queryset(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.event.orders
|
||||
qs = self.get_base_queryset()
|
||||
if 'fees' not in self.request.GET.getlist('exclude'):
|
||||
if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
|
||||
fqs = OrderFee.all
|
||||
@@ -227,11 +231,12 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
opq = OrderPosition.all
|
||||
else:
|
||||
opq = OrderPosition.objects
|
||||
if request.query_params.get('pdf_data', 'false') == 'true':
|
||||
if request.query_params.get('pdf_data', 'false') == 'true' and getattr(request, 'event', None):
|
||||
prefetch_related_objects([request.organizer], 'meta_properties')
|
||||
prefetch_related_objects(
|
||||
[request.event],
|
||||
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'), to_attr='meta_values_cached'),
|
||||
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'),
|
||||
to_attr='meta_values_cached'),
|
||||
'questions',
|
||||
'item_meta_properties',
|
||||
)
|
||||
@@ -266,13 +271,12 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
def _get_output_provider(self, identifier):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
prov = response(self.request.event)
|
||||
if prov.identifier == identifier:
|
||||
return prov
|
||||
raise NotFound('Unknown output provider.')
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['exclude'] = self.request.query_params.getlist('exclude')
|
||||
ctx['include'] = self.request.query_params.getlist('include')
|
||||
ctx['pdf_data'] = False
|
||||
return ctx
|
||||
|
||||
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
|
||||
def list(self, request, **kwargs):
|
||||
@@ -289,6 +293,45 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data, headers={'X-Page-Generated': date})
|
||||
|
||||
|
||||
class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
|
||||
def get_base_queryset(self):
|
||||
perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
return Order.objects.filter(
|
||||
event__organizer=self.request.organizer,
|
||||
event__in=self.request.auth.get_events_with_permission(perm, request=self.request)
|
||||
)
|
||||
elif self.request.user.is_authenticated:
|
||||
return Order.objects.filter(
|
||||
event__organizer=self.request.organizer,
|
||||
event__in=self.request.user.get_events_with_permission(perm, request=self.request)
|
||||
)
|
||||
else:
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
|
||||
return ctx
|
||||
|
||||
def get_base_queryset(self):
|
||||
return self.request.event.orders
|
||||
|
||||
def _get_output_provider(self, identifier):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
prov = response(self.request.event)
|
||||
if prov.identifier == identifier:
|
||||
return prov
|
||||
raise NotFound('Unknown output provider.')
|
||||
|
||||
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
|
||||
def download(self, request, output, **kwargs):
|
||||
provider = self._get_output_provider(output)
|
||||
@@ -612,7 +655,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
tz = pytz.timezone(self.request.event.settings.timezone)
|
||||
tz = ZoneInfo(self.request.event.settings.timezone)
|
||||
new_date = make_aware(datetime.datetime.combine(
|
||||
new_date,
|
||||
datetime.time(hour=23, minute=59, second=59)
|
||||
@@ -661,7 +704,16 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
|
||||
with language(order.locale, self.request.event.settings.region):
|
||||
payment = order.payments.last()
|
||||
|
||||
# OrderCreateSerializer creates at most one payment
|
||||
if payment and payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
|
||||
order.log_action(
|
||||
'pretix.event.order.payment.confirmed', {
|
||||
'local_id': payment.local_id,
|
||||
'provider': payment.provider,
|
||||
},
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth,
|
||||
)
|
||||
order_placed.send(self.request.event, order=order)
|
||||
if order.status == Order.STATUS_PAID:
|
||||
order_paid.send(self.request.event, order=order)
|
||||
@@ -936,6 +988,7 @@ with scopes_disabled():
|
||||
| Q(addon_to__attendee_email__icontains=value)
|
||||
| Q(order__code__istartswith=value)
|
||||
| Q(order__invoice_address__name_cached__icontains=value)
|
||||
| Q(order__invoice_address__company__icontains=value)
|
||||
| Q(order__email__icontains=value)
|
||||
| Q(pk__in=matching_media)
|
||||
)
|
||||
@@ -1181,7 +1234,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
||||
ftype, ignored = mimetypes.guess_type(image_file.name)
|
||||
extension = os.path.basename(image_file.name).split('.')[-1]
|
||||
else:
|
||||
img = Image.open(image_file)
|
||||
img = Image.open(image_file, formats=settings.PILLOW_FORMATS_QUESTIONS_IMAGE)
|
||||
ftype = Image.MIME[img.format]
|
||||
extensions = {
|
||||
'GIF': 'gif', 'TIFF': 'tif', 'BMP': 'bmp', 'JPEG': 'jpg', 'PNG': 'png'
|
||||
@@ -1772,11 +1825,24 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.invoices.prefetch_related('lines').select_related('order', 'refers').annotate(
|
||||
perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
|
||||
if getattr(self.request, 'event', None):
|
||||
qs = self.request.event.invoices
|
||||
elif isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
qs = Invoice.objects.filter(
|
||||
event__organizer=self.request.organizer,
|
||||
event__in=self.request.auth.get_events_with_permission(perm, request=self.request)
|
||||
)
|
||||
elif self.request.user.is_authenticated:
|
||||
qs = Invoice.objects.filter(
|
||||
event__organizer=self.request.organizer,
|
||||
event__in=self.request.user.get_events_with_permission(perm, request=self.request)
|
||||
)
|
||||
return qs.prefetch_related('lines').select_related('order', 'refers').annotate(
|
||||
nr=Concat('prefix', 'invoice_no')
|
||||
)
|
||||
|
||||
@action(detail=True, )
|
||||
@action(detail=True)
|
||||
def download(self, request, **kwargs):
|
||||
invoice = self.get_object()
|
||||
|
||||
@@ -1795,7 +1861,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
return resp
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def regenerate(self, request, **kwarts):
|
||||
def regenerate(self, request, **kwargs):
|
||||
inv = self.get_object()
|
||||
if inv.canceled:
|
||||
raise ValidationError('The invoice has already been canceled.')
|
||||
@@ -1805,7 +1871,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||
elif inv.sent_to_organizer:
|
||||
raise PermissionDenied('The invoice file has already been exported.')
|
||||
elif now().astimezone(self.request.event.timezone).date() - inv.date > datetime.timedelta(days=1):
|
||||
elif now().astimezone(inv.event.timezone).date() - inv.date > datetime.timedelta(days=1):
|
||||
raise PermissionDenied('The invoice file is too old to be regenerated.')
|
||||
else:
|
||||
inv = regenerate_invoice(inv)
|
||||
@@ -1820,7 +1886,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
return Response(status=204)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def reissue(self, request, **kwarts):
|
||||
def reissue(self, request, **kwargs):
|
||||
inv = self.get_object()
|
||||
if inv.canceled:
|
||||
raise ValidationError('The invoice has already been canceled.')
|
||||
|
||||
@@ -24,6 +24,8 @@ from decimal import Decimal
|
||||
import django_filters
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.db import transaction
|
||||
from django.db.models import OuterRef, Subquery, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
@@ -155,8 +157,13 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
qs = self.request.organizer.accepted_gift_cards
|
||||
else:
|
||||
qs = self.request.organizer.issued_gift_cards.all()
|
||||
s = GiftCardTransaction.objects.filter(
|
||||
card=OuterRef('pk')
|
||||
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
||||
return qs.prefetch_related(
|
||||
'issuer'
|
||||
).annotate(
|
||||
cached_value=Coalesce(Subquery(s), Decimal('0.00'))
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
# 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 contextlib
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from django.utils.timezone import now
|
||||
@@ -69,30 +67,9 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
def get_queryset(self):
|
||||
return self.request.event.vouchers.select_related('seat').all()
|
||||
|
||||
def _predict_quota_check(self, data, instance):
|
||||
# This method predicts if Voucher.clean_quota_needs_checking
|
||||
# *migh* later require a quota check. It is only approximate
|
||||
# and returns True a little too often. The point is to avoid
|
||||
# locks when we know we won't need them.
|
||||
if 'allow_ignore_quota' in data and data.get('allow_ignore_quota'):
|
||||
return False
|
||||
if instance and 'allow_ignore_quota' not in data and instance.allow_ignore_quota:
|
||||
return False
|
||||
|
||||
if 'block_quota' in data and not data.get('block_quota'):
|
||||
return False
|
||||
if instance and 'block_quota' not in data and not instance.block_quota:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@transaction.atomic()
|
||||
def create(self, request, *args, **kwargs):
|
||||
if self._predict_quota_check(request.data, None):
|
||||
lockfn = request.event.lock
|
||||
else:
|
||||
lockfn = contextlib.suppress # noop context manager
|
||||
with lockfn():
|
||||
return super().create(request, *args, **kwargs)
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
@@ -108,13 +85,9 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def update(self, request, *args, **kwargs):
|
||||
if self._predict_quota_check(request.data, self.get_object()):
|
||||
lockfn = request.event.lock
|
||||
else:
|
||||
lockfn = contextlib.suppress # noop context manager
|
||||
with lockfn():
|
||||
return super().update(request, *args, **kwargs)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
@@ -140,22 +113,18 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@action(detail=False, methods=['POST'])
|
||||
@transaction.atomic()
|
||||
def batch_create(self, request, *args, **kwargs):
|
||||
if any(self._predict_quota_check(d, None) for d in request.data):
|
||||
lockfn = request.event.lock
|
||||
else:
|
||||
lockfn = contextlib.suppress # noop context manager
|
||||
with lockfn():
|
||||
serializer = self.get_serializer(data=request.data, many=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
serializer.save(event=self.request.event)
|
||||
for i, v in enumerate(serializer.instance):
|
||||
v.log_action(
|
||||
'pretix.voucher.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data[i]
|
||||
)
|
||||
serializer = self.get_serializer(data=request.data, many=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
serializer.save(event=self.request.event)
|
||||
for i, v in enumerate(serializer.instance):
|
||||
v.log_action(
|
||||
'pretix.voucher.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data[i]
|
||||
)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@@ -189,6 +189,34 @@ class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
return d
|
||||
|
||||
|
||||
class ParametrizedWaitingListEntryWebhookEvent(ParametrizedWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
# do not use content_object, this is also called in deletion
|
||||
return {
|
||||
'notification_id': logentry.pk,
|
||||
'organizer': logentry.event.organizer.slug,
|
||||
'event': logentry.event.slug,
|
||||
'waitinglistentry': logentry.object_id,
|
||||
'action': logentry.action_type,
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedCustomerWebhookEvent(ParametrizedWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
customer = logentry.content_object
|
||||
if not customer:
|
||||
return None
|
||||
|
||||
return {
|
||||
'notification_id': logentry.pk,
|
||||
'organizer': customer.organizer.slug,
|
||||
'customer': customer.identifier,
|
||||
'action': logentry.action_type,
|
||||
}
|
||||
|
||||
|
||||
@receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events")
|
||||
def register_default_webhook_events(sender, **kwargs):
|
||||
return (
|
||||
@@ -321,6 +349,34 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
'pretix.event.testmode.deactivated',
|
||||
_('Test-Mode of shop has been deactivated'),
|
||||
),
|
||||
ParametrizedWaitingListEntryWebhookEvent(
|
||||
'pretix.event.orders.waitinglist.added',
|
||||
_('Waiting list entry added'),
|
||||
),
|
||||
ParametrizedWaitingListEntryWebhookEvent(
|
||||
'pretix.event.orders.waitinglist.changed',
|
||||
_('Waiting list entry changed'),
|
||||
),
|
||||
ParametrizedWaitingListEntryWebhookEvent(
|
||||
'pretix.event.orders.waitinglist.deleted',
|
||||
_('Waiting list entry deleted'),
|
||||
),
|
||||
ParametrizedWaitingListEntryWebhookEvent(
|
||||
'pretix.event.orders.waitinglist.voucher_assigned',
|
||||
_('Waiting list entry received voucher'),
|
||||
),
|
||||
ParametrizedCustomerWebhookEvent(
|
||||
'pretix.customer.created',
|
||||
_('Customer account created'),
|
||||
),
|
||||
ParametrizedCustomerWebhookEvent(
|
||||
'pretix.customer.changed',
|
||||
_('Customer account changed'),
|
||||
),
|
||||
ParametrizedCustomerWebhookEvent(
|
||||
'pretix.customer.anonymized',
|
||||
_('Customer account anonymized'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -62,27 +62,27 @@ class NamespacedCache:
|
||||
prefix = int(time.time())
|
||||
self.cache.set(self.prefixkey, prefix)
|
||||
|
||||
def set(self, key: str, value: str, timeout: int=300):
|
||||
def set(self, key: str, value: any, timeout: int=300):
|
||||
return self.cache.set(self._prefix_key(key), value, timeout)
|
||||
|
||||
def get(self, key: str) -> str:
|
||||
def get(self, key: str) -> any:
|
||||
return self.cache.get(self._prefix_key(key, known_prefix=self._last_prefix))
|
||||
|
||||
def get_or_set(self, key: str, default: Callable, timeout=300) -> str:
|
||||
def get_or_set(self, key: str, default: Callable, timeout=300) -> any:
|
||||
return self.cache.get_or_set(
|
||||
self._prefix_key(key, known_prefix=self._last_prefix),
|
||||
default=default,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
def get_many(self, keys: List[str]) -> Dict[str, str]:
|
||||
def get_many(self, keys: List[str]) -> Dict[str, any]:
|
||||
values = self.cache.get_many([self._prefix_key(key) for key in keys])
|
||||
newvalues = {}
|
||||
for k, v in values.items():
|
||||
newvalues[self._strip_prefix(k)] = v
|
||||
return newvalues
|
||||
|
||||
def set_many(self, values: Dict[str, str], timeout=300):
|
||||
def set_many(self, values: Dict[str, any], timeout=300):
|
||||
newvalues = {}
|
||||
for k, v in values.items():
|
||||
newvalues[self._prefix_key(k)] = v
|
||||
|
||||
@@ -134,8 +134,11 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
def template_name(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def compile_markdown(self, plaintext):
|
||||
return markdown_compile_email(plaintext)
|
||||
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str:
|
||||
body_md = markdown_compile_email(plain_body)
|
||||
body_md = self.compile_markdown(plain_body)
|
||||
htmlctx = {
|
||||
'site': settings.PRETIX_INSTANCE_NAME,
|
||||
'site_url': settings.SITE_URL,
|
||||
@@ -153,7 +156,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
|
||||
if plain_signature:
|
||||
signature_md = plain_signature.replace('\n', '<br>\n')
|
||||
signature_md = markdown_compile_email(signature_md)
|
||||
signature_md = self.compile_markdown(signature_md)
|
||||
htmlctx['signature'] = signature_md
|
||||
|
||||
if order:
|
||||
@@ -666,6 +669,11 @@ def base_placeholders(sender, **kwargs):
|
||||
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)),
|
||||
|
||||
@@ -37,8 +37,8 @@ import tempfile
|
||||
from collections import OrderedDict, namedtuple
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from defusedcsv import csv
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
@@ -68,7 +68,7 @@ class BaseExporter:
|
||||
self.events = event
|
||||
self.event = None
|
||||
e = self.events.first()
|
||||
self.timezone = e.timezone if e else pytz.timezone(settings.TIME_ZONE)
|
||||
self.timezone = e.timezone if e else ZoneInfo(settings.TIME_ZONE)
|
||||
else:
|
||||
self.events = Event.objects.filter(pk=event.pk)
|
||||
self.timezone = event.timezone
|
||||
@@ -140,7 +140,7 @@ class BaseExporter:
|
||||
"""
|
||||
return {}
|
||||
|
||||
def render(self, form_data: dict) -> Tuple[str, str, bytes]:
|
||||
def render(self, form_data: dict) -> Tuple[str, str, Optional[bytes]]:
|
||||
"""
|
||||
Render the exported file and return a tuple consisting of a filename, a file type
|
||||
and file content.
|
||||
@@ -157,6 +157,13 @@ class BaseExporter:
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
def available_for_user(self, user) -> bool:
|
||||
"""
|
||||
Allows to do additional checks whether an exporter is available based on the user who calls it. Note that
|
||||
``user`` may be ``None`` e.g. during API usage.
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
class OrganizerLevelExportMixin:
|
||||
@property
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.db.models import (
|
||||
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
|
||||
@@ -326,7 +326,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
yield self.ProgressSetTotal(total=qs.count())
|
||||
for order in qs.order_by('datetime').iterator():
|
||||
tz = pytz.timezone(self.event_object_cache[order.event_id].settings.timezone)
|
||||
tz = ZoneInfo(self.event_object_cache[order.event_id].settings.timezone)
|
||||
|
||||
row = [
|
||||
self.event_object_cache[order.event_id].slug,
|
||||
@@ -459,7 +459,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
yield self.ProgressSetTotal(total=qs.count())
|
||||
for op in qs.order_by('order__datetime').iterator():
|
||||
order = op.order
|
||||
tz = pytz.timezone(order.event.settings.timezone)
|
||||
tz = ZoneInfo(order.event.settings.timezone)
|
||||
row = [
|
||||
self.event_object_cache[order.event_id].slug,
|
||||
order.code,
|
||||
@@ -549,7 +549,9 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('End date'))
|
||||
headers += [
|
||||
_('Product'),
|
||||
_('Product ID'),
|
||||
_('Variation'),
|
||||
_('Variation ID'),
|
||||
_('Price'),
|
||||
_('Tax rate'),
|
||||
_('Tax rule'),
|
||||
@@ -631,7 +633,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
for op in ops:
|
||||
order = op.order
|
||||
tz = pytz.timezone(self.event_object_cache[order.event_id].settings.timezone)
|
||||
tz = ZoneInfo(self.event_object_cache[order.event_id].settings.timezone)
|
||||
row = [
|
||||
self.event_object_cache[order.event_id].slug,
|
||||
order.code,
|
||||
@@ -656,7 +658,9 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
row.append('')
|
||||
row += [
|
||||
str(op.item),
|
||||
str(op.item_id),
|
||||
str(op.variation) if op.variation else '',
|
||||
str(op.variation_id) if op.variation_id else '',
|
||||
op.price,
|
||||
op.tax_rate,
|
||||
str(op.tax_rule) if op.tax_rule else '',
|
||||
@@ -850,6 +854,8 @@ class TransactionListExporter(ListExporter):
|
||||
_('Tax rule ID'),
|
||||
_('Tax rule'),
|
||||
_('Tax value'),
|
||||
_('Gross total'),
|
||||
_('Tax total'),
|
||||
]
|
||||
|
||||
if form_data.get('_format') == 'xlsx':
|
||||
@@ -901,6 +907,8 @@ class TransactionListExporter(ListExporter):
|
||||
t.tax_rule_id or '',
|
||||
str(t.tax_rule.internal_name or t.tax_rule.name) if t.tax_rule_id else '',
|
||||
t.tax_value,
|
||||
t.price * t.count,
|
||||
t.tax_value * t.count,
|
||||
]
|
||||
|
||||
if form_data.get('_format') == 'xlsx':
|
||||
@@ -1024,7 +1032,7 @@ class PaymentListExporter(ListExporter):
|
||||
|
||||
yield self.ProgressSetTotal(total=len(objs))
|
||||
for obj in objs:
|
||||
tz = pytz.timezone(obj.order.event.settings.timezone)
|
||||
tz = ZoneInfo(obj.order.event.settings.timezone)
|
||||
if isinstance(obj, OrderPayment) and obj.payment_date:
|
||||
d2 = obj.payment_date.astimezone(tz).date().strftime('%Y-%m-%d')
|
||||
elif isinstance(obj, OrderRefund) and obj.execution_date:
|
||||
@@ -1143,7 +1151,7 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
def iterate_list(self, form_data):
|
||||
qs = GiftCardTransaction.objects.filter(
|
||||
card__issuer=self.organizer,
|
||||
).order_by('datetime').select_related('card', 'order', 'order__event')
|
||||
).order_by('datetime').select_related('card', 'order', 'order__event', 'acceptor')
|
||||
|
||||
if form_data.get('date_range'):
|
||||
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
|
||||
@@ -1159,6 +1167,7 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
_('Amount'),
|
||||
_('Currency'),
|
||||
_('Order'),
|
||||
_('Organizer'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
@@ -1170,6 +1179,7 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
obj.value,
|
||||
obj.card.currency,
|
||||
obj.order.full_code if obj.order else None,
|
||||
str(obj.acceptor or ""),
|
||||
]
|
||||
yield row
|
||||
|
||||
@@ -1203,7 +1213,7 @@ class GiftcardRedemptionListExporter(ListExporter):
|
||||
yield headers
|
||||
|
||||
for obj in objs:
|
||||
tz = pytz.timezone(obj.order.event.settings.timezone)
|
||||
tz = ZoneInfo(obj.order.event.settings.timezone)
|
||||
gc = GiftCard.objects.get(pk=obj.info_data.get('gift_card'))
|
||||
row = [
|
||||
obj.order.event.slug,
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from collections import OrderedDict
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.db.models import F, Q
|
||||
from django.dispatch import receiver
|
||||
@@ -137,7 +137,7 @@ class WaitingListExporter(ListExporter):
|
||||
|
||||
# which event should be used to output dates in columns "Start date" and "End date"
|
||||
event_for_date_columns = entry.subevent if entry.subevent else entry.event
|
||||
tz = pytz.timezone(entry.event.settings.timezone)
|
||||
tz = ZoneInfo(entry.event.settings.timezone)
|
||||
datetime_format = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
row = [
|
||||
|
||||
@@ -167,6 +167,7 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
|
||||
class PrefixForm(forms.Form):
|
||||
prefix = forms.CharField(widget=forms.HiddenInput)
|
||||
template_name = "django/forms/table.html"
|
||||
|
||||
|
||||
class SafeSessionWizardView(SessionWizardView):
|
||||
|
||||
@@ -38,10 +38,10 @@ import logging
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import dateutil.parser
|
||||
import pycountry
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@@ -61,6 +61,7 @@ from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries import countries
|
||||
from django_countries.fields import Country, CountryField
|
||||
from geoip2.errors import AddressNotFoundError
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
from phonenumber_field.phonenumber import PhoneNumber
|
||||
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
||||
@@ -356,9 +357,12 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
||||
def guess_country_from_request(request, event):
|
||||
if settings.HAS_GEOIP:
|
||||
g = GeoIP2()
|
||||
res = g.country(get_client_ip(request))
|
||||
if res['country_code'] and len(res['country_code']) == 2:
|
||||
return Country(res['country_code'])
|
||||
try:
|
||||
res = g.country(get_client_ip(request))
|
||||
if res['country_code'] and len(res['country_code']) == 2:
|
||||
return Country(res['country_code'])
|
||||
except AddressNotFoundError:
|
||||
pass
|
||||
return guess_country(event)
|
||||
|
||||
|
||||
@@ -496,14 +500,14 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
|
||||
file = BytesIO(data['content'])
|
||||
|
||||
try:
|
||||
image = Image.open(file)
|
||||
image = Image.open(file, formats=settings.PILLOW_FORMATS_QUESTIONS_IMAGE)
|
||||
# verify() must be called immediately after the constructor.
|
||||
image.verify()
|
||||
|
||||
# We want to do more than just verify(), so we need to re-open the file
|
||||
if hasattr(file, 'seek'):
|
||||
file.seek(0)
|
||||
image = Image.open(file)
|
||||
image = Image.open(file, formats=settings.PILLOW_FORMATS_QUESTIONS_IMAGE)
|
||||
|
||||
# load() is a potential DoS vector (see Django bug #18520), so we verify the size first
|
||||
if image.width > 10_000 or image.height > 10_000:
|
||||
@@ -562,7 +566,7 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
|
||||
return f
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault('ext_whitelist', (".png", ".jpg", ".jpeg", ".jfif", ".tif", ".tiff", ".bmp"))
|
||||
kwargs.setdefault('ext_whitelist', settings.FILE_UPLOAD_EXTENSIONS_QUESTION_IMAGE)
|
||||
kwargs.setdefault('max_size', settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -733,7 +737,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial = answers[0]
|
||||
else:
|
||||
initial = None
|
||||
tz = pytz.timezone(event.settings.timezone)
|
||||
tz = ZoneInfo(event.settings.timezone)
|
||||
help_text = rich_text(q.help_text)
|
||||
label = escape(q.question) # django-bootstrap3 calls mark_safe
|
||||
required = q.required and not self.all_optional
|
||||
@@ -822,11 +826,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
ext_whitelist=(
|
||||
".png", ".jpg", ".gif", ".jpeg", ".pdf", ".txt", ".docx", ".gif", ".svg",
|
||||
".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages",
|
||||
".bmp", ".tif", ".tiff"
|
||||
),
|
||||
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
|
||||
)
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
|
||||
63
src/pretix/base/forms/renderers.py
Normal file
63
src/pretix/base/forms/renderers.py
Normal file
@@ -0,0 +1,63 @@
|
||||
#
|
||||
# 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
|
||||
@@ -60,6 +60,18 @@ def replace_arabic_numbers(inp):
|
||||
return inp.translate(table)
|
||||
|
||||
|
||||
def format_placeholders_help_text(placeholders, event=None):
|
||||
placeholders = [(k, v.render_sample(event) if event else v) for k, v in placeholders.items()]
|
||||
placeholders.sort(key=lambda x: x[0])
|
||||
phs = [
|
||||
'<button type="button" class="content-placeholder" title="%s">{%s}</button>' % (_("Sample: %s") % v if v else "", k)
|
||||
for k, v in placeholders
|
||||
]
|
||||
return _('Available placeholders: {list}').format(
|
||||
list=' '.join(phs)
|
||||
)
|
||||
|
||||
|
||||
class DatePickerWidget(forms.DateInput):
|
||||
def __init__(self, attrs=None, date_format=None):
|
||||
attrs = attrs or {}
|
||||
|
||||
@@ -24,7 +24,7 @@ Django, for theoretically very valid reasons, creates migrations for *every sing
|
||||
we change on a model. Even the `help_text`! This makes sense, as we don't know if any
|
||||
database backend unknown to us might actually use this information for its database schema.
|
||||
|
||||
However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
|
||||
However, pretix only supports PostgreSQL and SQLite and we can be pretty
|
||||
certain that some changes to models will never require a change to the database. In this case,
|
||||
not creating a migration for certain changes will save us some performance while applying them
|
||||
*and* allow for a cleaner git history. Win-win!
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytz
|
||||
import pytz_deprecation_shim
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.timezone import override
|
||||
from django_scopes import scope
|
||||
@@ -60,7 +60,7 @@ class Command(BaseCommand):
|
||||
sys.exit(1)
|
||||
|
||||
locale = options.get("locale", None)
|
||||
timezone = pytz.timezone(options['timezone']) if options.get('timezone') else None
|
||||
timezone = pytz_deprecation_shim.timezone(options['timezone']) if options.get('timezone') else None
|
||||
|
||||
with scope(organizer=o):
|
||||
if options['event_slug']:
|
||||
|
||||
@@ -49,6 +49,9 @@ class BaseMediaType:
|
||||
def handle_unknown(self, organizer, identifier, user, auth):
|
||||
pass
|
||||
|
||||
def handle_new(self, organizer, medium, user, auth):
|
||||
pass
|
||||
|
||||
def __str__(self):
|
||||
return str(self.verbose_name)
|
||||
|
||||
@@ -108,9 +111,43 @@ class NfcUidMediaType(BaseMediaType):
|
||||
return m
|
||||
|
||||
|
||||
class NfcMf0aesMediaType(BaseMediaType):
|
||||
identifier = 'nfc_mf0aes'
|
||||
verbose_name = 'NFC Mifare Ultralight AES'
|
||||
medium_created_by_server = False
|
||||
supports_giftcard = True
|
||||
supports_orderposition = False
|
||||
|
||||
def handle_new(self, organizer, medium, user, auth):
|
||||
from pretix.base.models import GiftCard
|
||||
|
||||
if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool):
|
||||
with transaction.atomic():
|
||||
gc = GiftCard.objects.create(
|
||||
issuer=organizer,
|
||||
expires=organizer.default_gift_card_expiry,
|
||||
currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'),
|
||||
)
|
||||
medium.linked_giftcard = gc
|
||||
medium.save()
|
||||
medium.log_action(
|
||||
'pretix.reusable_medium.linked_giftcard.changed',
|
||||
user=user, auth=auth,
|
||||
data={
|
||||
'linked_giftcard': gc.pk
|
||||
}
|
||||
)
|
||||
gc.log_action(
|
||||
'pretix.giftcards.created',
|
||||
user=user, auth=auth,
|
||||
)
|
||||
return medium
|
||||
|
||||
|
||||
MEDIA_TYPES = {
|
||||
m.identifier: m for m in [
|
||||
BarcodePlainMediaType(),
|
||||
NfcUidMediaType(),
|
||||
NfcMf0aesMediaType(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ def metric_values():
|
||||
|
||||
# Metrics from redis
|
||||
if settings.HAS_REDIS:
|
||||
for key, value in redis.hscan_iter(REDIS_KEY):
|
||||
for key, value in redis.hscan_iter(REDIS_KEY, count=1000):
|
||||
dkey = key.decode("utf-8")
|
||||
splitted = dkey.split("{", 2)
|
||||
value = float(value.decode("utf-8"))
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
#
|
||||
from collections import OrderedDict
|
||||
from urllib.parse import urlsplit
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.middleware.common import CommonMiddleware
|
||||
from django.urls import get_script_prefix
|
||||
from django.urls import get_script_prefix, resolve
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
@@ -98,9 +98,9 @@ class LocaleMiddleware(MiddlewareMixin):
|
||||
tzname = request.user.timezone
|
||||
if tzname:
|
||||
try:
|
||||
timezone.activate(pytz.timezone(tzname))
|
||||
timezone.activate(ZoneInfo(tzname))
|
||||
request.timezone = tzname
|
||||
except pytz.UnknownTimeZoneError:
|
||||
except ZoneInfoNotFoundError:
|
||||
pass
|
||||
else:
|
||||
timezone.deactivate()
|
||||
@@ -230,6 +230,8 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
)
|
||||
|
||||
def process_response(self, request, resp):
|
||||
url = resolve(request.path_info)
|
||||
|
||||
if settings.DEBUG and resp.status_code >= 400:
|
||||
# Don't use CSP on debug error page as it breaks of Django's fancy error
|
||||
# pages
|
||||
@@ -249,20 +251,28 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
h = {
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'script-src': ['{static}'],
|
||||
'object-src': ["'none'"],
|
||||
'frame-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'frame-src': ['{static}'],
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}", "https://checkout.stripe.com"],
|
||||
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"] + img_src,
|
||||
'connect-src': ["{dynamic}", "{media}"],
|
||||
'img-src': ["{static}", "{media}", "data:"] + img_src,
|
||||
'font-src': ["{static}"],
|
||||
'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
|
||||
# single-sign-on this can be nearly anything so we cannot really restrict
|
||||
# single-sign-on this can be nearly anything, so we cannot really restrict
|
||||
# this. However, we'll restrict it to HTTPS.
|
||||
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
|
||||
}
|
||||
# Only include pay.google.com for wallet detection purposes on the Payment selection page
|
||||
if (
|
||||
url.url_name == "event.order.pay.change" or
|
||||
(url.url_name == "event.checkout" and url.kwargs['step'] == "payment")
|
||||
):
|
||||
h['script-src'].append('https://pay.google.com')
|
||||
h['frame-src'].append('https://pay.google.com')
|
||||
h['connect-src'].append('https://google.com/pay')
|
||||
if settings.LOG_CSP:
|
||||
h['report-uri'] = ["/csp_report/"]
|
||||
if 'Content-Security-Policy' in resp:
|
||||
|
||||
@@ -10,6 +10,7 @@ def initial_user(apps, schema_editor):
|
||||
user = User(email='admin@localhost')
|
||||
user.is_staff = True
|
||||
user.is_superuser = True
|
||||
user.needs_password_change = True
|
||||
user.password = make_password('admin')
|
||||
user.save()
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# Generated by Django 1.10.4 on 2017-02-03 14:21
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import django.core.validators
|
||||
import django.db.migrations.operations.special
|
||||
import django.db.models.deletion
|
||||
@@ -26,7 +28,7 @@ def forwards42(apps, schema_editor):
|
||||
for s in EventSetting.objects.filter(key='timezone').values('object_id', 'value')
|
||||
}
|
||||
for order in Order.objects.all():
|
||||
tz = pytz.timezone(etz.get(order.event_id, 'UTC'))
|
||||
tz = ZoneInfo(etz.get(order.event_id, 'UTC'))
|
||||
order.expires = order.expires.astimezone(tz).replace(hour=23, minute=59, second=59)
|
||||
order.save()
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
# Generated by Django 1.10.2 on 2016-10-19 17:57
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import pytz
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
@@ -15,7 +15,7 @@ def forwards(apps, schema_editor):
|
||||
for s in EventSetting.objects.filter(key='timezone').values('object_id', 'value')
|
||||
}
|
||||
for order in Order.objects.all():
|
||||
tz = pytz.timezone(etz.get(order.event_id, 'UTC'))
|
||||
tz = ZoneInfo(etz.get(order.event_id, 'UTC'))
|
||||
order.expires = order.expires.astimezone(tz).replace(hour=23, minute=59, second=59)
|
||||
order.save()
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import migrations, models
|
||||
from django_mysql.checks import mysql_connections
|
||||
|
||||
|
||||
def set_attendee_name_parts(apps, schema_editor):
|
||||
@@ -24,40 +23,12 @@ def set_attendee_name_parts(apps, schema_editor):
|
||||
ia.save(update_fields=['name_parts'])
|
||||
|
||||
|
||||
def check_mysqlversion(apps, schema_editor):
|
||||
errors = []
|
||||
any_conn_works = False
|
||||
conns = list(mysql_connections())
|
||||
found = 'Unknown version'
|
||||
for alias, conn in conns:
|
||||
if hasattr(conn, 'mysql_is_mariadb') and conn.mysql_is_mariadb and hasattr(conn, 'mysql_version'):
|
||||
if conn.mysql_version >= (10, 2, 7):
|
||||
any_conn_works = True
|
||||
else:
|
||||
found = 'MariaDB ' + '.'.join(str(v) for v in conn.mysql_version)
|
||||
elif hasattr(conn, 'mysql_version'):
|
||||
if conn.mysql_version >= (5, 7):
|
||||
any_conn_works = True
|
||||
else:
|
||||
found = 'MySQL ' + '.'.join(str(v) for v in conn.mysql_version)
|
||||
|
||||
if conns and not any_conn_works:
|
||||
raise ImproperlyConfigured(
|
||||
'As of pretix 2.2, you need MySQL 5.7+ or MariaDB 10.2.7+ to run pretix. However, we detected a '
|
||||
'database connection to {}'.format(found)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0101_auto_20181025_2255'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
check_mysqlversion, migrations.RunPython.noop
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='cartposition',
|
||||
old_name='attendee_name',
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Generated by Django 3.2.4 on 2021-09-30 10:25
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.db import migrations, models
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -15,7 +14,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='sent_to_customer',
|
||||
field=models.DateTimeField(blank=True, null=True, default=UTC.localize(datetime(1970, 1, 1, 0, 0, 0, 0))),
|
||||
field=models.DateTimeField(blank=True, null=True, default=datetime(1970, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc)),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -50,6 +50,6 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
'unique_together': {('event', 'secret')},
|
||||
} if 'mysql' not in settings.DATABASES['default']['ENGINE'] else {}
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
38
src/pretix/base/migrations/0242_auto_20230512_1008.py
Normal file
38
src/pretix/base/migrations/0242_auto_20230512_1008.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 3.2.18 on 2023-05-12 10:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0241_itemmetaproperties_required_values'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='giftcardacceptance',
|
||||
old_name='collector',
|
||||
new_name='acceptor',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='giftcardacceptance',
|
||||
name='active',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='giftcardacceptance',
|
||||
name='reusable_media',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='giftcardacceptance',
|
||||
name='issuer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gift_card_acceptor_acceptance', to='pretixbase.organizer'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='giftcardacceptance',
|
||||
unique_together={('issuer', 'acceptor')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.9 on 2023-06-26 10:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0242_auto_20230512_1008'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='os_name',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='os_version',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
]
|
||||
35
src/pretix/base/migrations/0244_mediumkeyset.py
Normal file
35
src/pretix/base/migrations/0244_mediumkeyset.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 3.2.18 on 2023-05-17 11:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0243_device_os_name_and_os_version'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='rsa_pubkey',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MediumKeySet',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('public_id', models.BigIntegerField(unique=True)),
|
||||
('media_type', models.CharField(max_length=100)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('uid_key', models.BinaryField()),
|
||||
('diversification_key', models.BinaryField()),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='medium_key_sets', to='pretixbase.organizer')),
|
||||
],
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='mediumkeyset',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('active', True)), fields=('organizer', 'media_type'), name='keyset_unique_active'),
|
||||
),
|
||||
]
|
||||
34
src/pretix/base/migrations/0245_discount_benefit_products.py
Normal file
34
src/pretix/base/migrations/0245_discount_benefit_products.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 4.2.4 on 2023-08-28 12:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0244_mediumkeyset"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="discount",
|
||||
name="benefit_apply_to_addons",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="discount",
|
||||
name="benefit_ignore_voucher_discounted",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="discount",
|
||||
name="benefit_limit_products",
|
||||
field=models.ManyToManyField(
|
||||
related_name="benefit_discounts", to="pretixbase.item"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="discount",
|
||||
name="benefit_same_products",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
509
src/pretix/base/migrations/0246_bigint.py
Normal file
509
src/pretix/base/migrations/0246_bigint.py
Normal file
@@ -0,0 +1,509 @@
|
||||
# Generated by Django 4.2.4 on 2023-09-26 12:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0245_discount_benefit_products"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name="logentry",
|
||||
new_name="pretixbase__datetim_b1fe5a_idx",
|
||||
old_fields=("datetime", "id"),
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name="order",
|
||||
new_name="pretixbase__datetim_66aff0_idx",
|
||||
old_fields=("datetime", "id"),
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name="order",
|
||||
new_name="pretixbase__last_mo_4ebf8b_idx",
|
||||
old_fields=("last_modified", "id"),
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name="reusablemedium",
|
||||
new_name="pretixbase__updated_093277_idx",
|
||||
old_fields=("updated", "id"),
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name="transaction",
|
||||
new_name="pretixbase__datetim_b20405_idx",
|
||||
old_fields=("datetime", "id"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="attendeeprofile",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="blockedticketsecret",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="cachedcombinedticket",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="cachedticket",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="cancellationrequest",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="cartposition",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="checkin",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="checkinlist",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="customer",
|
||||
name="locale",
|
||||
field=models.CharField(default="de", max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="device",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="discount",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="event",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="event_settingsstore",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="eventfooterlink",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="eventmetaproperty",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="eventmetavalue",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="exchangerate",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="gate",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="giftcard",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="giftcardacceptance",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="giftcardtransaction",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="globalsettingsobject_settingsstore",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="invoice",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="invoiceaddress",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="invoiceline",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="item",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="itemaddon",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="itembundle",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="itemcategory",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="itemmetaproperty",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="itemmetavalue",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="itemvariation",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="itemvariationmetavalue",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="logentry",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="mediumkeyset",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="notificationsetting",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="order",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="orderfee",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="orderpayment",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="orderposition",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="orderrefund",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="organizer",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="organizer_settingsstore",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="organizerfooterlink",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="question",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="questionanswer",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="questionoption",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="quota",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="revokedticketsecret",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="seat",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="seatcategorymapping",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="seatingplan",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="staffsession",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="staffsessionauditlog",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="subevent",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="subeventitem",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="subeventitemvariation",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="subeventmetavalue",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="taxrule",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="team",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="teamapitoken",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="teaminvite",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="u2fdevice",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="locale",
|
||||
field=models.CharField(default="de", max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="voucher",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="waitinglistentry",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="webauthndevice",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
]
|
||||
22
src/pretix/base/migrations/0247_checkinlist.py
Normal file
22
src/pretix/base/migrations/0247_checkinlist.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.2.4 on 2023-09-06 11:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0246_bigint"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="checkinlist",
|
||||
name="consider_tickets_used",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="checkinlist",
|
||||
name="ignore_in_statistics",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -97,7 +97,7 @@ def _transactions_mark_order_dirty(order_id, using=None):
|
||||
if getattr(dirty_transactions, 'order_ids', None) is None:
|
||||
dirty_transactions.order_ids = set()
|
||||
|
||||
if _check_for_dirty_orders not in [func for savepoint_id, func in conn.run_on_commit]:
|
||||
if _check_for_dirty_orders not in [func for (savepoint_id, func, *__) in conn.run_on_commit]:
|
||||
transaction.on_commit(_check_for_dirty_orders, using)
|
||||
dirty_transactions.order_ids.clear() # This is necessary to clean up after old threads with rollbacked transactions
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ class LoggedModel(models.Model, LoggingMixin):
|
||||
|
||||
return LogEntry.objects.filter(
|
||||
content_type=self.logs_content_type, object_id=self.pk
|
||||
).select_related('user', 'event', 'oauth_application', 'api_token', 'device')
|
||||
).select_related('user', 'event', 'event__organizer', 'oauth_application', 'api_token', 'device')
|
||||
|
||||
|
||||
class LockModel:
|
||||
|
||||
@@ -62,6 +62,16 @@ class CheckinList(LoggedModel):
|
||||
'and valid for check-in regardless of which date they are purchased for. '
|
||||
'You can limit their validity through the advanced check-in rules, '
|
||||
'though.'))
|
||||
ignore_in_statistics = models.BooleanField(
|
||||
verbose_name=pgettext_lazy('checkin', 'Ignore check-ins on this list in statistics'),
|
||||
default=False
|
||||
)
|
||||
consider_tickets_used = models.BooleanField(
|
||||
verbose_name=pgettext_lazy('checkin', 'Tickets with a check-in on this list should be considered "used"'),
|
||||
help_text=_('This is relevant in various situations, e.g. for deciding if a ticket can still be canceled by '
|
||||
'the customer.'),
|
||||
default=True
|
||||
)
|
||||
include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'),
|
||||
default=False,
|
||||
help_text=_('With this option, people will be able to check in even if the '
|
||||
@@ -265,16 +275,16 @@ class CheckinList(LoggedModel):
|
||||
# * in pretix.helpers.jsonlogic_boolalg
|
||||
# * in checkinrules.js
|
||||
# * in libpretixsync
|
||||
# * in pretixscan-ios (in the future)
|
||||
# * in pretixscan-ios
|
||||
top_level_operators = {
|
||||
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
|
||||
}
|
||||
allowed_operators = top_level_operators | {
|
||||
'buildTime', 'objectList', 'lookup', 'var',
|
||||
'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before'
|
||||
}
|
||||
allowed_vars = {
|
||||
'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days',
|
||||
'minutes_since_last_entry', 'minutes_since_first_entry',
|
||||
'minutes_since_last_entry', 'minutes_since_first_entry', 'gate',
|
||||
}
|
||||
if not rules or not isinstance(rules, dict):
|
||||
return rules
|
||||
@@ -299,6 +309,10 @@ class CheckinList(LoggedModel):
|
||||
raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.')
|
||||
return rules
|
||||
|
||||
if operator in ('entries_since', 'entries_before'):
|
||||
if len(values) != 1 or "buildTime" not in values[0]:
|
||||
raise ValidationError(f'Operator "{operator}" takes exactly one "buildTime" argument.')
|
||||
|
||||
if operator in ('or', 'and') and seen_nonbool:
|
||||
raise ValidationError('You cannot use OR/AND logic on a level below a comparison operator.')
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user