forked from CGM_Public/pretix_original
Compare commits
506 Commits
a
...
release/4.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f91d1a6dc | ||
|
|
1a091c9705 | ||
|
|
115e1af96b | ||
|
|
5add5656fe | ||
|
|
621c7e1682 | ||
|
|
beddab5d03 | ||
|
|
52308cb793 | ||
|
|
4a45f4f877 | ||
|
|
a0e8d50356 | ||
|
|
8d8c6bcee5 | ||
|
|
04521287eb | ||
|
|
8963166bcf | ||
|
|
9f71056b56 | ||
|
|
24acc7e159 | ||
|
|
3825c384bf | ||
|
|
3626d8c642 | ||
|
|
7a5d5d08c0 | ||
|
|
276bb12edb | ||
|
|
28411feff6 | ||
|
|
1118b01dbd | ||
|
|
22221fc413 | ||
|
|
50ece6c1fc | ||
|
|
5de2d60ff8 | ||
|
|
4eed155acb | ||
|
|
5915abd7cb | ||
|
|
52d926d698 | ||
|
|
2b81e983d4 | ||
|
|
55dc7fd988 | ||
|
|
01bcf114b2 | ||
|
|
adbf76a09f | ||
|
|
316081658a | ||
|
|
0aff74afc6 | ||
|
|
f13daafa39 | ||
|
|
ff449b801f | ||
|
|
9bdb72aa06 | ||
|
|
148727917b | ||
|
|
33b25aa981 | ||
|
|
e01c417c1e | ||
|
|
6d050b4d2b | ||
|
|
e9c440ceed | ||
|
|
5cd79ee2b0 | ||
|
|
15c6e22414 | ||
|
|
8d04a0183a | ||
|
|
2f6881934e | ||
|
|
8f7bc59214 | ||
|
|
9bf3b54a83 | ||
|
|
82cd6e320d | ||
|
|
e308b38d6f | ||
|
|
6b7a2e1981 | ||
|
|
d19cb14dc1 | ||
|
|
3e8e454e92 | ||
|
|
f46de92303 | ||
|
|
aeba9542be | ||
|
|
a380044639 | ||
|
|
4cbe50f3a2 | ||
|
|
278d54e780 | ||
|
|
9634598952 | ||
|
|
b60583168b | ||
|
|
ea8630d3d7 | ||
|
|
3cdf578c14 | ||
|
|
f7c0921f18 | ||
|
|
a7ae556478 | ||
|
|
a755bfd22c | ||
|
|
22920a7318 | ||
|
|
cf6a8c333a | ||
|
|
2623bfd2db | ||
|
|
dcc1a93b72 | ||
|
|
3aeea82d2e | ||
|
|
732621f121 | ||
|
|
24e7be4142 | ||
|
|
20c6f0b327 | ||
|
|
e4817518d8 | ||
|
|
35c443f90f | ||
|
|
de669156cd | ||
|
|
d8e3b49b04 | ||
|
|
567f68965d | ||
|
|
65f6892896 | ||
|
|
ec9800b215 | ||
|
|
9010d8f6a1 | ||
|
|
f260945fdf | ||
|
|
b7241825c3 | ||
|
|
c9ed155870 | ||
|
|
69d0a20674 | ||
|
|
b699e8977a | ||
|
|
4f25d8ba89 | ||
|
|
71f5303a5e | ||
|
|
69f91e54e6 | ||
|
|
bcee2c231a | ||
|
|
d1745bb703 | ||
|
|
f468b393c0 | ||
|
|
4b0c38e4ee | ||
|
|
6768bbb486 | ||
|
|
f2c9b46d3e | ||
|
|
b7a3db2ac0 | ||
|
|
307a6654f2 | ||
|
|
f2cc8c77e8 | ||
|
|
830d48255e | ||
|
|
df2b428aa1 | ||
|
|
7d1d05de02 | ||
|
|
921d8b6057 | ||
|
|
0b13ec49f1 | ||
|
|
62ef89f87c | ||
|
|
d0920caf32 | ||
|
|
d0474afdfe | ||
|
|
4ab298dd10 | ||
|
|
db39b89ae4 | ||
|
|
162ae3ead7 | ||
|
|
65a7e8516e | ||
|
|
cccd4af6dd | ||
|
|
751cfdf203 | ||
|
|
898776b617 | ||
|
|
b4db81d6c3 | ||
|
|
e96fdf2a2c | ||
|
|
9052b39f9c | ||
|
|
725725223e | ||
|
|
71b4c3117f | ||
|
|
5e1dc5ac5b | ||
|
|
0b403b7b3a | ||
|
|
be46a00d38 | ||
|
|
22f3412ad0 | ||
|
|
c23a3fcfcd | ||
|
|
1412b0afdb | ||
|
|
60f14b6a68 | ||
|
|
dc440d6cc5 | ||
|
|
7cef7b4d5d | ||
|
|
659a587cdf | ||
|
|
ee72009e73 | ||
|
|
69375f4092 | ||
|
|
76475039b5 | ||
|
|
8a8524a346 | ||
|
|
e7996c693a | ||
|
|
551bd3e284 | ||
|
|
645a7489df | ||
|
|
dff4bf2841 | ||
|
|
fc1e2ca3b0 | ||
|
|
2a0748a008 | ||
|
|
41d3f39fc7 | ||
|
|
8e481905e2 | ||
|
|
552e130fed | ||
|
|
f8b879c2e6 | ||
|
|
9e5403333c | ||
|
|
2486c3d205 | ||
|
|
ee9bf25ae1 | ||
|
|
c4d3c48837 | ||
|
|
d97b7b4fb6 | ||
|
|
fd83e91174 | ||
|
|
6e3b62696e | ||
|
|
a0b8fedaa7 | ||
|
|
a4961d9dfb | ||
|
|
889f972768 | ||
|
|
478ab633e0 | ||
|
|
0e4047018e | ||
|
|
cbeaffa415 | ||
|
|
09972b62c9 | ||
|
|
3b2eec659d | ||
|
|
4e6c61316c | ||
|
|
c308939f3c | ||
|
|
41604ebdd9 | ||
|
|
e2adf1fdb3 | ||
|
|
90d9ddebb5 | ||
|
|
684b8e4102 | ||
|
|
a83e963ea5 | ||
|
|
c9392236f5 | ||
|
|
57d68eaddb | ||
|
|
ab1e063a93 | ||
|
|
4b76dcedc4 | ||
|
|
55752a4319 | ||
|
|
5bde98e349 | ||
|
|
1b2ca87f2d | ||
|
|
8562c2f103 | ||
|
|
ea0283266f | ||
|
|
cc912b9ea7 | ||
|
|
ebae275a2d | ||
|
|
02f8bcbe23 | ||
|
|
4ae7cc9f50 | ||
|
|
c2f2e157d7 | ||
|
|
ebf0320c2c | ||
|
|
b05fa89010 | ||
|
|
ab0f76c7bb | ||
|
|
e4c1f30b9d | ||
|
|
9ffeafa6a5 | ||
|
|
cb7e014966 | ||
|
|
7b7a8e655e | ||
|
|
ac46172f6b | ||
|
|
31eda01464 | ||
|
|
0adddb3084 | ||
|
|
0b91f9da31 | ||
|
|
0a7ba606c6 | ||
|
|
959b076b52 | ||
|
|
06b0ef906a | ||
|
|
4f1607040e | ||
|
|
38a3fa23ed | ||
|
|
baae16d9e7 | ||
|
|
e2d9cc10e8 | ||
|
|
f0f8bbb2b0 | ||
|
|
8d57544ba3 | ||
|
|
065533ee4d | ||
|
|
fb2ad2711f | ||
|
|
52004e5c46 | ||
|
|
9fcd099531 | ||
|
|
d8cf3552ba | ||
|
|
cc04f66a48 | ||
|
|
86d28b3f21 | ||
|
|
07b401b7af | ||
|
|
8ff4f778bf | ||
|
|
5174d38017 | ||
|
|
5681ea121d | ||
|
|
4e2a3b45da | ||
|
|
d796c399a6 | ||
|
|
bee5b5f1af | ||
|
|
028be3a5c3 | ||
|
|
990fd9a40c | ||
|
|
746c9af531 | ||
|
|
47208afcec | ||
|
|
b215a0bd04 | ||
|
|
6c94b62bb1 | ||
|
|
d7a32d985b | ||
|
|
fac3e7f7bd | ||
|
|
8e8dce7ccf | ||
|
|
465e11c765 | ||
|
|
7e6c512a76 | ||
|
|
5d0b8c5084 | ||
|
|
6008e3d11c | ||
|
|
aeeebc0b4b | ||
|
|
a568a37f4c | ||
|
|
b74f5508b7 | ||
|
|
a9b0651345 | ||
|
|
c126445fe0 | ||
|
|
f8b8d92f2c | ||
|
|
a66fdc5084 | ||
|
|
6d6883b343 | ||
|
|
482968175b | ||
|
|
5d6302d5fd | ||
|
|
92fbe76327 | ||
|
|
2fd2462ef1 | ||
|
|
19881dff44 | ||
|
|
798fdbf25b | ||
|
|
1718a537e6 | ||
|
|
b4d8936b78 | ||
|
|
683bc3f6dc | ||
|
|
b79c95f334 | ||
|
|
7821ba09ec | ||
|
|
af2600fd52 | ||
|
|
058282a583 | ||
|
|
16fa01ac60 | ||
|
|
2b5ce5364b | ||
|
|
4a93866cc3 | ||
|
|
fbc1d862a1 | ||
|
|
638daa2c19 | ||
|
|
d400a3c7d3 | ||
|
|
76f6947529 | ||
|
|
db7e299af1 | ||
|
|
7ed204ffc0 | ||
|
|
14e0d9cbf4 | ||
|
|
65fb492728 | ||
|
|
a4f64e94cc | ||
|
|
67ba1f81e4 | ||
|
|
cc8282bef1 | ||
|
|
c7fc52cabe | ||
|
|
4bc04de325 | ||
|
|
9a2ecae021 | ||
|
|
e55fb303c0 | ||
|
|
185761e9e6 | ||
|
|
a78cb039da | ||
|
|
5f07f0e80b | ||
|
|
4021b28d5f | ||
|
|
8717b1f8db | ||
|
|
b789e64830 | ||
|
|
4d595e3fd4 | ||
|
|
482a9c6af7 | ||
|
|
99faa8b300 | ||
|
|
f0be03f93a | ||
|
|
5f12fca88a | ||
|
|
fa88686856 | ||
|
|
59a6e4130e | ||
|
|
35ccc3a9af | ||
|
|
ef9e7fd92a | ||
|
|
d7acd2b6bf | ||
|
|
2bf5a0ce8a | ||
|
|
7310fb3c6e | ||
|
|
069dd02ebc | ||
|
|
70e4b02370 | ||
|
|
b20797fe4b | ||
|
|
aee8de54ed | ||
|
|
6d7e16c147 | ||
|
|
f511f5a646 | ||
|
|
6ba690932f | ||
|
|
46b3e3c739 | ||
|
|
3550197fc4 | ||
|
|
db96211c7a | ||
|
|
758179f12f | ||
|
|
98409b0a22 | ||
|
|
06ffa0bcd5 | ||
|
|
18917769ef | ||
|
|
34e95bc7d2 | ||
|
|
5ba7ee3516 | ||
|
|
3706eff795 | ||
|
|
28331e7538 | ||
|
|
62218ca0c6 | ||
|
|
14e2834a72 | ||
|
|
032653cec4 | ||
|
|
f3b355e9f3 | ||
|
|
f7d2645e76 | ||
|
|
fb89e31c1c | ||
|
|
5e1cff53b4 | ||
|
|
61cef87c9d | ||
|
|
2fcab70e3b | ||
|
|
1414db35b7 | ||
|
|
1d32d7a2d2 | ||
|
|
9966912799 | ||
|
|
a37ed6f001 | ||
|
|
a307cf8934 | ||
|
|
0e900b74d7 | ||
|
|
7193da42c2 | ||
|
|
48eb580ee8 | ||
|
|
50a5622178 | ||
|
|
66027aed59 | ||
|
|
8d62e3e2af | ||
|
|
a8ce4845e2 | ||
|
|
efdb834a73 | ||
|
|
fd060b792c | ||
|
|
31df3e2129 | ||
|
|
c71ba79e55 | ||
|
|
6304b34600 | ||
|
|
81cc7540ec | ||
|
|
adced71706 | ||
|
|
8c7ed38441 | ||
|
|
b4d7d9bf76 | ||
|
|
af3006a5bd | ||
|
|
d313e076a2 | ||
|
|
216bac2807 | ||
|
|
8351e51cfe | ||
|
|
b2d74dc652 | ||
|
|
ea1322165b | ||
|
|
c65883b328 | ||
|
|
dfd37cc5e3 | ||
|
|
4c71995560 | ||
|
|
02034cacbf | ||
|
|
d098cda8a8 | ||
|
|
0b8432b2c5 | ||
|
|
9be6ad4124 | ||
|
|
fdc77f6bd8 | ||
|
|
e3d0a18bee | ||
|
|
81c271ee2a | ||
|
|
e981f00dc7 | ||
|
|
2daf35c39e | ||
|
|
c9530c56af | ||
|
|
f3e31287f4 | ||
|
|
c9aaa343e6 | ||
|
|
87a196c4df | ||
|
|
a220f1678b | ||
|
|
c8fa0852b2 | ||
|
|
fe3433106c | ||
|
|
f8086daf34 | ||
|
|
66f75a5614 | ||
|
|
6f30c347c0 | ||
|
|
3596fa9c5a | ||
|
|
e3c7cd7c6d | ||
|
|
194042dca5 | ||
|
|
3be6e83f33 | ||
|
|
4262bce2b5 | ||
|
|
73ab962e16 | ||
|
|
13a86fc6f3 | ||
|
|
9d6f11718a | ||
|
|
c9d3428996 | ||
|
|
d4ef16b31a | ||
|
|
6a35e7d3cd | ||
|
|
463443d606 | ||
|
|
6f0da5c2ca | ||
|
|
c1344422a5 | ||
|
|
c2bd3dde44 | ||
|
|
9e51736232 | ||
|
|
5b27ce1265 | ||
|
|
0757542f4f | ||
|
|
12be98c888 | ||
|
|
51e6b02aa9 | ||
|
|
acc4a167b1 | ||
|
|
dd9429bbfa | ||
|
|
768bb8c106 | ||
|
|
cbdafac999 | ||
|
|
96f694cf61 | ||
|
|
5576829ebf | ||
|
|
b0d67e92ac | ||
|
|
63e28723d2 | ||
|
|
cc0656f169 | ||
|
|
849c8e719a | ||
|
|
a3ec2a4061 | ||
|
|
00a7187a7a | ||
|
|
701c4f768e | ||
|
|
cf751d38d2 | ||
|
|
888402a4bf | ||
|
|
1134f610fd | ||
|
|
8ae4304c7d | ||
|
|
357092ec44 | ||
|
|
70a5c76d79 | ||
|
|
7a4db8ea23 | ||
|
|
223b160c0c | ||
|
|
30c1771d29 | ||
|
|
b3b7b9bbab | ||
|
|
be040cd6ea | ||
|
|
c6665ec2e6 | ||
|
|
fd16ef1e4d | ||
|
|
39557fc452 | ||
|
|
408397a639 | ||
|
|
d4a2500204 | ||
|
|
e74d9e56cf | ||
|
|
f3767ab4ac | ||
|
|
5d13f5f885 | ||
|
|
451d3fce05 | ||
|
|
ccb61e0f56 | ||
|
|
b6273adc57 | ||
|
|
0bf7bba6ba | ||
|
|
7090e0bae2 | ||
|
|
c75cb0b8e3 | ||
|
|
3dbf22f670 | ||
|
|
f26cbdc257 | ||
|
|
6b4adccee5 | ||
|
|
c2a8286022 | ||
|
|
4145887a9b | ||
|
|
9f4b834abc | ||
|
|
8fcc314f09 | ||
|
|
94a7d02ab1 | ||
|
|
ad2943263c | ||
|
|
5210ac3a78 | ||
|
|
0e9600a7bf | ||
|
|
eccba09452 | ||
|
|
c8a830ecde | ||
|
|
aed64d16f6 | ||
|
|
d16f6167f6 | ||
|
|
77d59248e5 | ||
|
|
a0e05f8af6 | ||
|
|
9b8a47c8b8 | ||
|
|
b3d692276c | ||
|
|
55543e12f6 | ||
|
|
1e16185c02 | ||
|
|
cd900e24bd | ||
|
|
0dbedc07ce | ||
|
|
f71877b7fc | ||
|
|
f69e270e4d | ||
|
|
533939cae4 | ||
|
|
91ec5fd78c | ||
|
|
0056fb447b | ||
|
|
20c4d12e98 | ||
|
|
e13c567e84 | ||
|
|
9fef97a7c6 | ||
|
|
e68a995376 | ||
|
|
6abdb40ef5 | ||
|
|
43cc06b0a1 | ||
|
|
d17476cd75 | ||
|
|
5c3bfd2a71 | ||
|
|
033b8d70e7 | ||
|
|
bd22c2afc9 | ||
|
|
b355733f53 | ||
|
|
e1f924c4ce | ||
|
|
8038f4e173 | ||
|
|
5c55219d45 | ||
|
|
bfd37af467 | ||
|
|
b2509e120c | ||
|
|
e2339acd09 | ||
|
|
c15b4fa03c | ||
|
|
c4aa2e0484 | ||
|
|
361eeb7159 | ||
|
|
0109e1806f | ||
|
|
30aadac099 | ||
|
|
0458f1b2dc | ||
|
|
e006ca3feb | ||
|
|
1f31ee2ea1 | ||
|
|
2d37b0df77 | ||
|
|
4133e5ac4d | ||
|
|
0fd3d0fe71 | ||
|
|
d0685e99ad | ||
|
|
c6fd5bc864 | ||
|
|
9fa935099f | ||
|
|
83b5a325e3 | ||
|
|
97e12c5003 | ||
|
|
e6db8340f2 | ||
|
|
3cf9caa5d3 | ||
|
|
2ffd68ace7 | ||
|
|
0231be63b4 | ||
|
|
fae8bc254e | ||
|
|
1d5c700fa2 | ||
|
|
e61775d5c1 | ||
|
|
e767c6a68d | ||
|
|
832235411f | ||
|
|
1f0f7b752f | ||
|
|
3117eceb72 | ||
|
|
c1b39782fd | ||
|
|
860cfc3227 | ||
|
|
45859a07dd | ||
|
|
04fb8efc0d | ||
|
|
fdb8a3720b | ||
|
|
5638d68894 | ||
|
|
f64042280a | ||
|
|
50060cdc8d | ||
|
|
4499f58e3d | ||
|
|
918e4a5a89 | ||
|
|
15a86fd796 | ||
|
|
4126d20f1c | ||
|
|
ea3edf83f8 | ||
|
|
9a42819b56 | ||
|
|
3e4ba28700 | ||
|
|
9014ffcc28 | ||
|
|
48f4bcf88c | ||
|
|
b7dfb3697e | ||
|
|
475a5be351 | ||
|
|
8254d8f5cc |
14
.github/workflows/tests.yml
vendored
14
.github/workflows/tests.yml
vendored
@@ -18,17 +18,17 @@ jobs:
|
|||||||
name: Tests
|
name: Tests
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.6, 3.7, 3.8]
|
python-version: ["3.7", "3.8", "3.9"]
|
||||||
database: [sqlite, postgres, mysql]
|
database: [sqlite, postgres, mysql]
|
||||||
exclude:
|
exclude:
|
||||||
- database: mysql
|
- database: mysql
|
||||||
python-version: 3.7
|
python-version: "3.8"
|
||||||
- database: sqlite
|
|
||||||
python-version: 3.7
|
|
||||||
- database: mysql
|
- database: mysql
|
||||||
python-version: 3.6
|
python-version: "3.9"
|
||||||
- database: sqlite
|
- database: sqlite
|
||||||
python-version: 3.6
|
python-version: "3.7"
|
||||||
|
- database: sqlite
|
||||||
|
python-version: "3.8"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: getong/mariadb-action@v1.1
|
- uses: getong/mariadb-action@v1.1
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pip-
|
${{ runner.os }}-pip-
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: sudo apt update && sudo apt install gettext mysql-client
|
run: sudo apt update && sudo apt install gettext mariadb-client-10.3
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ Example::
|
|||||||
A comma-separated list of plugins that are not available even though they are installed.
|
A comma-separated list of plugins that are not available even though they are installed.
|
||||||
Defaults to an empty string.
|
Defaults to an empty string.
|
||||||
|
|
||||||
|
``plugins_show_meta``
|
||||||
|
Whether to show authors and versions of plugins, defaults to ``on``.
|
||||||
|
|
||||||
``auth_backends``
|
``auth_backends``
|
||||||
A comma-separated list of available auth backends. Defaults to ``pretix.base.auth.NativeAuthBackend``.
|
A comma-separated list of available auth backends. Defaults to ``pretix.base.auth.NativeAuthBackend``.
|
||||||
|
|
||||||
@@ -220,12 +223,30 @@ Example::
|
|||||||
``user``, ``password``
|
``user``, ``password``
|
||||||
The SMTP user data to use for the connection. Empty by default.
|
The SMTP user data to use for the connection. Empty by default.
|
||||||
|
|
||||||
|
``tls``, ``ssl``
|
||||||
|
Use STARTTLS or SSL for the SMTP connection. Off by default.
|
||||||
|
|
||||||
``from``
|
``from``
|
||||||
The email address to set as ``From`` header in outgoing emails by the system.
|
The email address to set as ``From`` header in outgoing emails by the system.
|
||||||
Default: ``pretix@localhost``
|
Default: ``pretix@localhost``
|
||||||
|
|
||||||
``tls``, ``ssl``
|
``from_notifications``
|
||||||
Use STARTTLS or SSL for the SMTP connection. Off by default.
|
The email address to set as ``From`` header in admin notification emails by the system.
|
||||||
|
Defaults to the value of ``from``.
|
||||||
|
|
||||||
|
``from_organizers``
|
||||||
|
The email address to set as ``From`` header in outgoing emails by the system sent on behalf of organizers.
|
||||||
|
Defaults to the value of ``from``.
|
||||||
|
|
||||||
|
``custom_sender_verification_required``
|
||||||
|
If this is on (the default), organizers need to verify email addresses they want to use as senders in their event.
|
||||||
|
|
||||||
|
``custom_sender_spf_string``
|
||||||
|
If this is set to a valid SPF string, pretix will show a warning if organizers use a sender address from a domain
|
||||||
|
that does not include this value.
|
||||||
|
|
||||||
|
``custom_smtp_allow_private_networks``
|
||||||
|
If this is off (the default), custom SMTP servers cannot be private network addresses.
|
||||||
|
|
||||||
``admins``
|
``admins``
|
||||||
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
|
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
|
||||||
@@ -282,7 +303,7 @@ You can use an existing memcached server as pretix's caching backend::
|
|||||||
``location``
|
``location``
|
||||||
The location of memcached, either a host:port combination or a socket file.
|
The location of memcached, either a host:port combination or a socket file.
|
||||||
|
|
||||||
If no memcached is configured, pretix will use Django's built-in local-memory caching method.
|
If no memcached is configured, pretix will use redis for caching. If neither is configured, pretix will not use any caching.
|
||||||
|
|
||||||
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
|
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
|
||||||
shared memcached instance, not multiple ones, because cache invalidations would not be
|
shared memcached instance, not multiple ones, because cache invalidations would not be
|
||||||
@@ -445,8 +466,10 @@ You can configure the maximum file size for uploading various files::
|
|||||||
max_size_image = 12
|
max_size_image = 12
|
||||||
; Max upload size for favicons in MiB, defaults to 1 MiB
|
; Max upload size for favicons in MiB, defaults to 1 MiB
|
||||||
max_size_favicon = 2
|
max_size_favicon = 2
|
||||||
; Max upload size for email attachments in MiB, defaults to 10 MiB
|
; Max upload size for email attachments of manually sent emails in MiB, defaults to 10 MiB
|
||||||
max_size_email_attachment = 15
|
max_size_email_attachment = 15
|
||||||
|
; Max upload size for email attachments of automatically sent emails in MiB, defaults to 1 MiB
|
||||||
|
max_size_email_auto_attachment = 2
|
||||||
; Max upload size for other files in MiB, defaults to 10 MiB
|
; Max upload size for other files in MiB, defaults to 10 MiB
|
||||||
; This includes all file upload type order questions
|
; This includes all file upload type order questions
|
||||||
max_size_other = 100
|
max_size_other = 100
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ Linux and firewalls, we recommend that you start with `ufw`_.
|
|||||||
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
|
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
|
||||||
installations except for evaluation purposes.
|
installations except for evaluation purposes.
|
||||||
|
|
||||||
.. 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**.
|
|
||||||
|
|
||||||
.. warning:: By default, using `ufw` in conjunction will not have any effect. Please make sure to either bind the exposed
|
.. warning:: By default, using `ufw` in conjunction will not have any effect. Please make sure to either bind the exposed
|
||||||
ports of your docker container explicitly to 127.0.0.1 or configure docker to respect any set up firewall
|
ports of your docker container explicitly to 127.0.0.1 or configure docker to respect any set up firewall
|
||||||
rules.
|
rules.
|
||||||
@@ -61,6 +58,9 @@ directory writable to the user that runs pretix inside the docker container::
|
|||||||
Database
|
Database
|
||||||
--------
|
--------
|
||||||
|
|
||||||
|
.. warning:: **Please use PostgreSQL for all new installations**. If you need to go for MySQL, make sure you run
|
||||||
|
**MySQL 5.7 or newer** or **MariaDB 10.2.7 or newer**.
|
||||||
|
|
||||||
Next, we need a database and a database user. We can create these with any kind of database managing tool or directly on
|
Next, we need a database and a database user. We can create these with any kind of database managing tool or directly on
|
||||||
our database's shell. Please make sure that UTF8 is used as encoding for the best compatibility. You can check this with
|
our database's shell. Please make sure that UTF8 is used as encoding for the best compatibility. You can check this with
|
||||||
the following command::
|
the following command::
|
||||||
@@ -91,6 +91,8 @@ When using MySQL, make sure you set the character set of the database to ``utf8m
|
|||||||
|
|
||||||
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
You will also need to make sure that ``sql_mode`` in your ``my.cnf`` file does **not** include ``ONLY_FULL_GROUP_BY``.
|
||||||
|
|
||||||
Redis
|
Redis
|
||||||
-----
|
-----
|
||||||
|
|
||||||
@@ -106,6 +108,18 @@ Now restart redis-server::
|
|||||||
|
|
||||||
# systemctl restart redis-server
|
# systemctl restart redis-server
|
||||||
|
|
||||||
|
In this setup, systemd will delete ``/var/run/redis`` on every redis restart, which will cause issues with pretix. To
|
||||||
|
prevent this, you can execute::
|
||||||
|
|
||||||
|
# systemctl edit redis-server
|
||||||
|
|
||||||
|
And insert the following::
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# Keep the directory around so that pretix.service in docker does not need to be
|
||||||
|
# restarted when redis is restarted.
|
||||||
|
RuntimeDirectoryPreserve=yes
|
||||||
|
|
||||||
.. warning:: Setting the socket permissions to 777 is a possible security problem. If you have untrusted users on your
|
.. warning:: Setting the socket permissions to 777 is a possible security problem. If you have untrusted users on your
|
||||||
system or have high security requirements, please don't do this and let redis listen to a TCP socket
|
system or have high security requirements, please don't do this and let redis listen to a TCP socket
|
||||||
instead. We recommend the socket approach because the TCP socket in combination with docker's networking
|
instead. We recommend the socket approach because the TCP socket in combination with docker's networking
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ Linux and firewalls, we recommend that you start with `ufw`_.
|
|||||||
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
|
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
|
||||||
installations except for evaluation purposes.
|
installations except for evaluation purposes.
|
||||||
|
|
||||||
.. 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**.
|
|
||||||
|
|
||||||
Unix user
|
Unix user
|
||||||
---------
|
---------
|
||||||
|
|
||||||
@@ -50,6 +47,9 @@ In this guide, all code lines prepended with a ``#`` symbol are commands that yo
|
|||||||
Database
|
Database
|
||||||
--------
|
--------
|
||||||
|
|
||||||
|
.. warning:: **Please use PostgreSQL for all new installations**. If you need to go for MySQL, make sure you run
|
||||||
|
**MySQL 5.7 or newer** or **MariaDB 10.2.7 or newer**.
|
||||||
|
|
||||||
Having the database server installed, we still need a database and a database user. We can create these with any kind
|
Having the database server installed, we still need a database and a database user. We can create these with any kind
|
||||||
of database managing tool or directly on our database's shell. Please make sure that UTF8 is used as encoding for the
|
of database managing tool or directly on our database's shell. Please make sure that UTF8 is used as encoding for the
|
||||||
best compatibility. You can check this with the following command::
|
best compatibility. You can check this with the following command::
|
||||||
@@ -65,6 +65,8 @@ When using MySQL, make sure you set the character set of the database to ``utf8m
|
|||||||
|
|
||||||
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
You will also need to make sure that ``sql_mode`` in your ``my.cnf`` file does **not** include ``ONLY_FULL_GROUP_BY``.
|
||||||
|
|
||||||
Package dependencies
|
Package dependencies
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
@@ -142,7 +144,7 @@ If you're running MySQL, also install the client library::
|
|||||||
|
|
||||||
(venv)$ pip3 install mysqlclient
|
(venv)$ pip3 install mysqlclient
|
||||||
|
|
||||||
Note that you need Python 3.6 or newer. You can find out your Python version using ``python -V``.
|
Note that you need Python 3.7 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::
|
||||||
|
|
||||||
@@ -259,14 +261,14 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /static/ {
|
location /static/ {
|
||||||
alias /var/pretix/venv/lib/python3.7/site-packages/pretix/static.dist/;
|
alias /var/pretix/venv/lib/python3.10/site-packages/pretix/static.dist/;
|
||||||
access_log off;
|
access_log off;
|
||||||
expires 365d;
|
expires 365d;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.. note:: Remember to replace the ``python3.7`` in the ``/static/`` path in the config
|
.. note:: Remember to replace the ``python3.10`` in the ``/static/`` path in the config
|
||||||
above with your python version.
|
above with your python version.
|
||||||
|
|
||||||
We recommend reading about setting `strong encryption settings`_ for your web server.
|
We recommend reading about setting `strong encryption settings`_ for your web server.
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ following endpoint:
|
|||||||
"hardware_brand": "Samsung",
|
"hardware_brand": "Samsung",
|
||||||
"hardware_model": "Galaxy S",
|
"hardware_model": "Galaxy S",
|
||||||
"software_brand": "pretixdroid",
|
"software_brand": "pretixdroid",
|
||||||
"software_version": "4.1.0"
|
"software_version": "4.1.0",
|
||||||
|
"info": {"arbitrary": "data"}
|
||||||
}
|
}
|
||||||
|
|
||||||
You will receive a response equivalent to the response of your initialization request.
|
You will receive a response equivalent to the response of your initialization request.
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ Possible permissions are:
|
|||||||
* Can view vouchers
|
* Can view vouchers
|
||||||
* Can change vouchers
|
* Can change vouchers
|
||||||
|
|
||||||
|
.. _`rest-compat`:
|
||||||
|
|
||||||
Compatibility
|
Compatibility
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ that your clients can deal with them properly:
|
|||||||
* Support of new HTTP methods for a given API endpoint
|
* Support of new HTTP methods for a given API endpoint
|
||||||
* Support of new query parameters for a given API endpoint
|
* Support of new query parameters for a given API endpoint
|
||||||
* New fields contained in API responses
|
* New fields contained in API responses
|
||||||
|
* Response body structure or message texts on failed requests (``4xx``, ``5xx`` response codes)
|
||||||
|
|
||||||
We treat the following types of changes as *backwards-incompatible*:
|
We treat the following types of changes as *backwards-incompatible*:
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ For example, if you want users to be redirected to ``https://example.org/order/r
|
|||||||
either enter ``https://example.org`` or ``https://example.org/order/``.
|
either enter ``https://example.org`` or ``https://example.org/order/``.
|
||||||
|
|
||||||
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
|
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
|
||||||
**regardless of whether it was successful or not**. Make sure you use our API to check if the payment actually
|
**regardless of whether it was successful or not**. We will append an ``error=…`` query parameter with an error
|
||||||
|
message, but you should not rely on that and instead make sure you use our API to check if the payment actually
|
||||||
worked! Your final URL could look like this::
|
worked! Your final URL could look like this::
|
||||||
|
|
||||||
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234
|
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234
|
||||||
|
|||||||
@@ -611,8 +611,12 @@ Order position endpoints
|
|||||||
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
|
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
|
||||||
accepts a number of optional requests in the body.
|
accepts a number of optional requests in the body.
|
||||||
|
|
||||||
**Tip:** Instead of an ID, you can also use the ``secret`` field as the lookup parameter.
|
**Tip:** Instead of an ID, you can also use the ``secret`` field as the lookup parameter. In this case, you should
|
||||||
|
always set ``untrusted_input=true`` as a query parameter to avoid security issues.
|
||||||
|
|
||||||
|
:query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never
|
||||||
|
as an ``id``. This should be always set if you are passing through untrusted, scanned
|
||||||
|
data to avoid guessing of ticket IDs.
|
||||||
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||||
you do not implement question handling in your user interface, you **must**
|
you do not implement question handling in your user interface, you **must**
|
||||||
set this to ``false``. In that case, questions will just be ignored. Defaults
|
set this to ``false``. In that case, questions will just be ignored. Defaults
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
Resources and endpoints
|
Resources and endpoints
|
||||||
=======================
|
=======================
|
||||||
|
|
||||||
|
With a few exceptions, this only lists resources bundled in the pretix core modules.
|
||||||
|
Additional endpoints are provided by pretix plugins. Some of them are documented
|
||||||
|
at :ref:`plugin-docs`.
|
||||||
|
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
@@ -33,4 +38,4 @@ Resources and endpoints
|
|||||||
exporters
|
exporters
|
||||||
sendmail_rules
|
sendmail_rules
|
||||||
billing_invoices
|
billing_invoices
|
||||||
billing_var
|
billing_var
|
||||||
@@ -58,6 +58,12 @@ lines list of objects The actual invo
|
|||||||
created before this field was introduced as well as for
|
created before this field was introduced as well as for
|
||||||
all lines not created by a product (e.g. a shipping or
|
all lines not created by a product (e.g. a shipping or
|
||||||
cancellation fee).
|
cancellation fee).
|
||||||
|
├ subevent integer Event series date ID used to create this line. Note that everything
|
||||||
|
about the subevent might have changed since the creation
|
||||||
|
of the invoice. Can be ``null`` for all invoice lines
|
||||||
|
created before this field was introduced as well as for
|
||||||
|
all lines not created by a product (e.g. a shipping or
|
||||||
|
cancellation fee) as well as for all events that are not a series.
|
||||||
├ fee_type string Fee type, e.g. ``shipping``, ``service``, ``payment``,
|
├ fee_type string Fee type, e.g. ``shipping``, ``service``, ``payment``,
|
||||||
``cancellation``, ``giftcard``, or ``other. Can be ``null`` for
|
``cancellation``, ``giftcard``, or ``other. Can be ``null`` for
|
||||||
all invoice lines
|
all invoice lines
|
||||||
@@ -120,6 +126,10 @@ internal_reference string Customer's refe
|
|||||||
|
|
||||||
The attribute ``lines.event_location`` has been added.
|
The attribute ``lines.event_location`` has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.6
|
||||||
|
|
||||||
|
The attribute ``lines.subevent`` has been added.
|
||||||
|
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
@@ -185,6 +195,7 @@ Endpoints
|
|||||||
"description": "Budget Ticket",
|
"description": "Budget Ticket",
|
||||||
"item": 1234,
|
"item": 1234,
|
||||||
"variation": 245,
|
"variation": 245,
|
||||||
|
"subevent": null,
|
||||||
"fee_type": null,
|
"fee_type": null,
|
||||||
"fee_internal_type": null,
|
"fee_internal_type": null,
|
||||||
"event_date_from": "2017-12-27T10:00:00Z",
|
"event_date_from": "2017-12-27T10:00:00Z",
|
||||||
@@ -274,6 +285,7 @@ Endpoints
|
|||||||
"description": "Budget Ticket",
|
"description": "Budget Ticket",
|
||||||
"item": 1234,
|
"item": 1234,
|
||||||
"variation": 245,
|
"variation": 245,
|
||||||
|
"subevent": null,
|
||||||
"fee_type": null,
|
"fee_type": null,
|
||||||
"fee_internal_type": null,
|
"fee_internal_type": null,
|
||||||
"event_date_from": "2017-12-27T10:00:00Z",
|
"event_date_from": "2017-12-27T10:00:00Z",
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ active boolean If ``false``, t
|
|||||||
description multi-lingual string A public description of the variation. May contain
|
description multi-lingual string A public description of the variation. May contain
|
||||||
Markdown syntax or can be ``null``.
|
Markdown syntax or can be ``null``.
|
||||||
position integer An integer, used for sorting
|
position integer An integer, used for sorting
|
||||||
|
require_approval boolean If ``true``, orders with this variation will need to be
|
||||||
|
approved by the event organizer before they can be
|
||||||
|
paid.
|
||||||
require_membership boolean If ``true``, booking this variation requires an active membership.
|
require_membership boolean If ``true``, booking this variation requires an active membership.
|
||||||
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
|
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
|
||||||
be hidden from users without a valid membership.
|
be hidden from users without a valid membership.
|
||||||
@@ -76,6 +79,7 @@ Endpoints
|
|||||||
"en": "S"
|
"en": "S"
|
||||||
},
|
},
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_hidden": false,
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
@@ -97,6 +101,7 @@ Endpoints
|
|||||||
"en": "L"
|
"en": "L"
|
||||||
},
|
},
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_hidden": false,
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
@@ -147,6 +152,7 @@ Endpoints
|
|||||||
"price": "10.00",
|
"price": "10.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_hidden": false,
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
@@ -183,6 +189,7 @@ Endpoints
|
|||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
"default_price": "10.00",
|
"default_price": "10.00",
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_hidden": false,
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
@@ -209,6 +216,7 @@ Endpoints
|
|||||||
"price": "10.00",
|
"price": "10.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_hidden": false,
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
@@ -266,6 +274,7 @@ Endpoints
|
|||||||
"price": "10.00",
|
"price": "10.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"active": false,
|
"active": false,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_hidden": false,
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ positions list of objects List of order p
|
|||||||
non-canceled positions are included.
|
non-canceled positions are included.
|
||||||
fees list of objects List of fees included in the order total. By default, only
|
fees list of objects List of fees included in the order total. By default, only
|
||||||
non-canceled fees are included.
|
non-canceled fees are included.
|
||||||
|
├ id integer Internal ID of the fee record
|
||||||
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
|
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
|
||||||
``other``)
|
``other``)
|
||||||
├ value money (string) Fee amount
|
├ value money (string) Fee amount
|
||||||
@@ -132,6 +133,14 @@ last_modified datetime Last modificati
|
|||||||
|
|
||||||
The ``item`` and ``variation`` query parameters have been added.
|
The ``item`` and ``variation`` query parameters have been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.6
|
||||||
|
|
||||||
|
The ``subevent`` query parameters has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.8
|
||||||
|
|
||||||
|
The ``order.fees.id`` attribute has been added.
|
||||||
|
|
||||||
|
|
||||||
.. _order-position-resource:
|
.. _order-position-resource:
|
||||||
|
|
||||||
@@ -433,6 +442,7 @@ List of all orders
|
|||||||
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
|
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
|
||||||
you will not notice it using this method.
|
you will not notice it using this method.
|
||||||
:query datetime created_since: Only return orders that have been created since the given date.
|
:query datetime created_since: Only return orders that have been created since the given date.
|
||||||
|
:query integer subevent: Only return orders with a position that contains this subevent ID. *Warning:* Result will also include orders if they contain mixed subevents, and it will even return orders where the subevent is only contained in a canceled position.
|
||||||
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive after, and it considers the **end** of the subevent (or its start, if the end is not set).
|
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive after, and it considers the **end** of the subevent (or its start, if the end is not set).
|
||||||
:query datetime subevent_before: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive before, and it considers the **start** of the subevent.
|
:query datetime subevent_before: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive before, and it considers the **start** of the subevent.
|
||||||
:query string exclude: Exclude a field from the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times.
|
:query string exclude: Exclude a field from the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times.
|
||||||
@@ -730,6 +740,37 @@ Generating new secrets
|
|||||||
:statuscode 401: Authentication failure
|
:statuscode 401: Authentication failure
|
||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/regenerate_secrets/
|
||||||
|
|
||||||
|
Triggers generation of a new ``secret`` attribute for a single order position.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23/regenerate_secrets/ 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
|
||||||
|
|
||||||
|
(Full order position resource, see above.)
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer of the event
|
||||||
|
:param event: The ``slug`` field of the event
|
||||||
|
:param code: The ``id`` field of the order position to update
|
||||||
|
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: The order position could not be updated due to invalid submitted data.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order position.
|
||||||
|
|
||||||
Deleting orders
|
Deleting orders
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
@@ -834,6 +875,7 @@ Creating orders
|
|||||||
* ``comment`` (optional)
|
* ``comment`` (optional)
|
||||||
* ``custom_followup_at`` (optional)
|
* ``custom_followup_at`` (optional)
|
||||||
* ``checkin_attention`` (optional)
|
* ``checkin_attention`` (optional)
|
||||||
|
* ``require_approval`` (optional)
|
||||||
* ``invoice_address`` (optional)
|
* ``invoice_address`` (optional)
|
||||||
|
|
||||||
* ``company``
|
* ``company``
|
||||||
@@ -893,8 +935,9 @@ Creating orders
|
|||||||
|
|
||||||
* ``force`` (optional). If set to ``true``, quotas will be ignored.
|
* ``force`` (optional). If set to ``true``, quotas will be ignored.
|
||||||
* ``send_email`` (optional). If set to ``true``, the same emails will be sent as for a regular order, regardless of
|
* ``send_email`` (optional). If set to ``true``, the same emails will be sent as for a regular order, regardless of
|
||||||
whether these emails are enabled for certain sales channels. Defaults to
|
whether these emails are enabled for certain sales channels. If set to ``null``, behavior will be controlled by pretix'
|
||||||
``false``. Used to be ``send_mail`` before pretix 3.14.
|
settings based on the sales channels (added in pretix 4.7). Defaults to ``false``.
|
||||||
|
Used to be ``send_mail`` before pretix 3.14.
|
||||||
|
|
||||||
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
|
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
|
||||||
to incrementing integers starting with ``1``. Then, you can reference one of these
|
to incrementing integers starting with ``1``. Then, you can reference one of these
|
||||||
@@ -1039,6 +1082,9 @@ Order state operations
|
|||||||
will instead stay paid, but all positions will be removed (or marked as canceled) and replaced by the cancellation
|
will instead stay paid, but all positions will be removed (or marked as canceled) and replaced by the cancellation
|
||||||
fee as the only component of the order.
|
fee as the only component of the order.
|
||||||
|
|
||||||
|
You can control whether the customer is notified through ``send_email`` (defaults to ``true``).
|
||||||
|
You can pass a ``comment`` that can be visible to the user if it is used in the email template.
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -1050,6 +1096,7 @@ Order state operations
|
|||||||
|
|
||||||
{
|
{
|
||||||
"send_email": true,
|
"send_email": true,
|
||||||
|
"comment": "Event was canceled.",
|
||||||
"cancellation_fee": null
|
"cancellation_fee": null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1635,6 +1682,8 @@ Order position ticket download
|
|||||||
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||||
seconds.
|
seconds.
|
||||||
|
|
||||||
|
.. _rest-orderpositions-manipulate:
|
||||||
|
|
||||||
Manipulating individual positions
|
Manipulating individual positions
|
||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
@@ -1642,6 +1691,11 @@ Manipulating individual positions
|
|||||||
|
|
||||||
The ``PATCH`` method has been added for individual positions.
|
The ``PATCH`` method has been added for individual positions.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.8
|
||||||
|
|
||||||
|
The ``PATCH`` method now supports changing items, variations, subevents, seats, prices, and tax rules.
|
||||||
|
The ``POST`` endpoint to add individual positions has been added.
|
||||||
|
|
||||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
||||||
|
|
||||||
Updates specific fields on an order position. Currently, only the following fields are supported:
|
Updates specific fields on an order position. Currently, only the following fields are supported:
|
||||||
@@ -1668,6 +1722,21 @@ Manipulating individual positions
|
|||||||
and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value
|
and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value
|
||||||
``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it.
|
``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it.
|
||||||
|
|
||||||
|
* ``item``
|
||||||
|
|
||||||
|
* ``variation``
|
||||||
|
|
||||||
|
* ``subevent``
|
||||||
|
|
||||||
|
* ``seat`` (specified as a string mapping to a ``string_guid``)
|
||||||
|
|
||||||
|
* ``price``
|
||||||
|
|
||||||
|
* ``tax_rule``
|
||||||
|
|
||||||
|
Changing parameters such as ``item`` or ``price`` will **not** automatically trigger creation of a new invoice,
|
||||||
|
you need to take care of that yourself.
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -1689,7 +1758,7 @@ Manipulating individual positions
|
|||||||
Vary: Accept
|
Vary: Accept
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
(Full order resource, see above.)
|
(Full order position resource, see above.)
|
||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer of the event
|
:param organizer: The ``slug`` field of the organizer of the event
|
||||||
:param event: The ``slug`` field of the event
|
:param event: The ``slug`` field of the event
|
||||||
@@ -1700,9 +1769,83 @@ Manipulating individual positions
|
|||||||
:statuscode 401: Authentication failure
|
:statuscode 401: Authentication failure
|
||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
|
||||||
|
|
||||||
|
Adds a new position to an order. Currently, only the following fields are supported:
|
||||||
|
|
||||||
|
* ``order`` (mandatory, specified as a string mapping to a ``code``)
|
||||||
|
|
||||||
|
* ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` of the parent position)
|
||||||
|
|
||||||
|
* ``item`` (mandatory)
|
||||||
|
|
||||||
|
* ``variation`` (mandatory depending on item)
|
||||||
|
|
||||||
|
* ``subevent`` (mandatory depending on event)
|
||||||
|
|
||||||
|
* ``seat`` (specified as a string mapping to a ``string_guid``, mandatory depending on event and item)
|
||||||
|
|
||||||
|
* ``price`` (default price will be used if unset)
|
||||||
|
|
||||||
|
* ``attendee_email``
|
||||||
|
|
||||||
|
* ``attendee_name_parts`` or ``attendee_name``
|
||||||
|
|
||||||
|
* ``company``
|
||||||
|
|
||||||
|
* ``street``
|
||||||
|
|
||||||
|
* ``zipcode``
|
||||||
|
|
||||||
|
* ``city``
|
||||||
|
|
||||||
|
* ``country``
|
||||||
|
|
||||||
|
* ``state``
|
||||||
|
|
||||||
|
* ``answers``: Validation is handled the same way as when creating orders through the API. You are therefore
|
||||||
|
expected to provide ``question``, ``answer``, and possibly ``options``. ``question_identifier``
|
||||||
|
and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value
|
||||||
|
``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it.
|
||||||
|
|
||||||
|
This will **not** automatically trigger creation of a new invoice, you need to take care of that yourself.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"order": "ABC12",
|
||||||
|
"item": 5,
|
||||||
|
"addon_to": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 201 Created
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
(Full order position resource, see above.)
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer of the event
|
||||||
|
:param event: The ``slug`` field of the event
|
||||||
|
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: The position could not be created due to invalid submitted data.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this position.
|
||||||
|
|
||||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
||||||
|
|
||||||
Deletes an order position, identified by its internal ID.
|
Cancels an order position, identified by its internal ID.
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
@@ -1728,6 +1871,128 @@ Manipulating individual positions
|
|||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
:statuscode 404: The requested order position does not exist.
|
:statuscode 404: The requested order position does not exist.
|
||||||
|
|
||||||
|
Changing order contents
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
While you can :ref:`change positions individually <rest-orderpositions-manipulate>` sometimes it is necessary to make
|
||||||
|
multiple changes to an order at once within one transaction. This makes it possible to e.g. swap the seats of two
|
||||||
|
attendees in an order without running into conflicts. This interface also offers some possibilities not available
|
||||||
|
otherwise, such as splitting an order or changing fees.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.8
|
||||||
|
|
||||||
|
This endpoint has been added to the system.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/change/
|
||||||
|
|
||||||
|
Performs a change operation on an order. You can supply the following fields:
|
||||||
|
|
||||||
|
* ``patch_positions``: A list of objects with the two keys ``position`` specifying an order position ID and
|
||||||
|
``body`` specifying the desired changed values of the position (``item``, ``variation``, ``subevent``, ``seat``,
|
||||||
|
``price``, ``tax_rule``).
|
||||||
|
|
||||||
|
* ``cancel_positions``: A list of objects with the single key ``position`` specifying an order position ID.
|
||||||
|
|
||||||
|
* ``split_positions``: A list of objects with the single key ``position`` specifying an order position ID.
|
||||||
|
|
||||||
|
* ``create_positions``: A list of objects describing new order positions with the same fields supported as when
|
||||||
|
creating them individually through the ``POST …/orderpositions/`` endpoint.
|
||||||
|
|
||||||
|
* ``patch_fees``: A list of objects with the two keys ``fee`` specifying an order fee ID and
|
||||||
|
``body`` specifying the desired changed values of the position (``value``).
|
||||||
|
|
||||||
|
* ``cancel_fees``: A list of objects with the single key ``fee`` specifying an order fee ID.
|
||||||
|
|
||||||
|
* ``recalculate_taxes``: If set to ``"keep_net"``, all taxes will be recalculated based on the tax rule and invoice
|
||||||
|
address, the net price will be kept. If set to ``"keep_gross"``, the gross price will be kept. If set to ``null``
|
||||||
|
(the default) the taxes are not recalculated.
|
||||||
|
|
||||||
|
* ``send_email``: If set to ``true``, the customer will be notified about the change. Defaults to ``false``.
|
||||||
|
|
||||||
|
* ``reissue_invoice``: If set to ``true`` and an invoice exists for the order, it will be canceled and a new invoice
|
||||||
|
will be issued. Defaults to ``true``.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"cancel_positions": [
|
||||||
|
{
|
||||||
|
"position": 12373
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"patch_positions": [
|
||||||
|
{
|
||||||
|
"position": 12374,
|
||||||
|
"body": {
|
||||||
|
"item": 12,
|
||||||
|
"variation": None,
|
||||||
|
"subevent": 562,
|
||||||
|
"seat": "seat-guid-2",
|
||||||
|
"price": "99.99",
|
||||||
|
"tax_rule": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"split_positions": [
|
||||||
|
{
|
||||||
|
"position": 12375
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"create_positions": [
|
||||||
|
{
|
||||||
|
"item": 12,
|
||||||
|
"variation": None,
|
||||||
|
"subevent": 562,
|
||||||
|
"seat": "seat-guid-2",
|
||||||
|
"price": "99.99",
|
||||||
|
"addon_to": 12374,
|
||||||
|
"attendee_name": "Peter",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cancel_fees": [
|
||||||
|
{
|
||||||
|
"fee": 49
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"change_fees": [
|
||||||
|
{
|
||||||
|
"fee": 51,
|
||||||
|
"body": {
|
||||||
|
"value": "12.00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reissue_invoice": true,
|
||||||
|
"send_email": true,
|
||||||
|
"recalculate_taxes": "keep_gross"
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
(Full order position resource, see above.)
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer of the event
|
||||||
|
:param event: The ``slug`` field of the event
|
||||||
|
:param code: The ``code`` field of the order to update
|
||||||
|
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: The order could not be updated due to invalid submitted data.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
|
||||||
|
|
||||||
|
|
||||||
Order payment endpoints
|
Order payment endpoints
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|||||||
@@ -16,15 +16,22 @@ Field Type Description
|
|||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
id integer Internal ID of the tax rule
|
id integer Internal ID of the tax rule
|
||||||
name multi-lingual string The tax rules' name
|
name multi-lingual string The tax rules' name
|
||||||
|
internal_name string An optional name that is only used in the backend
|
||||||
rate decimal (string) Tax rate in percent
|
rate decimal (string) Tax rate in percent
|
||||||
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
|
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
|
||||||
the specified product price
|
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
|
||||||
home_country string Merchant country (required for reverse charge), can be
|
home_country string Merchant country (required for reverse charge), can be
|
||||||
``null`` or empty string
|
``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``)
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
.. versionchanged:: 4.6
|
||||||
|
|
||||||
|
The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added.
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
|
|
||||||
@@ -56,9 +63,11 @@ Endpoints
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "VAT"},
|
"name": {"en": "VAT"},
|
||||||
|
"internal_name": "VAT",
|
||||||
"rate": "19.00",
|
"rate": "19.00",
|
||||||
"price_includes_tax": true,
|
"price_includes_tax": true,
|
||||||
"eu_reverse_charge": false,
|
"eu_reverse_charge": false,
|
||||||
|
"keep_gross_if_rate_changes": false,
|
||||||
"home_country": "DE"
|
"home_country": "DE"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -94,9 +103,11 @@ Endpoints
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "VAT"},
|
"name": {"en": "VAT"},
|
||||||
|
"internal_name": "VAT",
|
||||||
"rate": "19.00",
|
"rate": "19.00",
|
||||||
"price_includes_tax": true,
|
"price_includes_tax": true,
|
||||||
"eu_reverse_charge": false,
|
"eu_reverse_charge": false,
|
||||||
|
"keep_gross_if_rate_changes": false,
|
||||||
"home_country": "DE"
|
"home_country": "DE"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,9 +151,11 @@ Endpoints
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "VAT"},
|
"name": {"en": "VAT"},
|
||||||
|
"internal_name": "VAT",
|
||||||
"rate": "19.00",
|
"rate": "19.00",
|
||||||
"price_includes_tax": true,
|
"price_includes_tax": true,
|
||||||
"eu_reverse_charge": false,
|
"eu_reverse_charge": false,
|
||||||
|
"keep_gross_if_rate_changes": false,
|
||||||
"home_country": "DE"
|
"home_country": "DE"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +198,11 @@ Endpoints
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "VAT"},
|
"name": {"en": "VAT"},
|
||||||
|
"internal_name": "VAT",
|
||||||
"rate": "20.00",
|
"rate": "20.00",
|
||||||
"price_includes_tax": true,
|
"price_includes_tax": true,
|
||||||
"eu_reverse_charge": false,
|
"eu_reverse_charge": false,
|
||||||
|
"keep_gross_if_rate_changes": false,
|
||||||
"home_country": "DE"
|
"home_country": "DE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ Algorithms
|
|||||||
==========
|
==========
|
||||||
|
|
||||||
The business logic inside pretix is full of complex algorithms making decisions based on all the hundreds of settings
|
The business logic inside pretix is full of complex algorithms making decisions based on all the hundreds of settings
|
||||||
and input parameters available. Some of them are documented here as graphs, either because fully understanding them is very
|
and input parameters available. Some of them are documented here as graphs, either because fully understanding them is very important
|
||||||
when working on features close to them, or because they also need to be re-implemented by client-side components like our
|
when working on features close to them, or because they also need to be re-implemented by client-side components like our
|
||||||
ticket scanning apps and we want to ensure the implementations are as similar as possible to avoid confusion.
|
ticket scanning apps and we want to ensure the implementations are as similar as possible to avoid confusion.
|
||||||
|
|
||||||
|
|||||||
@@ -20,20 +20,31 @@ Basically, three pre-defined flows are supported:
|
|||||||
* Authentication mechanisms that rely on **redirection**, e.g. to an OAuth provider. These can be implemented by
|
* Authentication mechanisms that rely on **redirection**, e.g. to an OAuth provider. These can be implemented by
|
||||||
supplying a ``authentication_url`` method and implementing a custom return view.
|
supplying a ``authentication_url`` method and implementing a custom return view.
|
||||||
|
|
||||||
Authentication backends are *not* collected through a signal. Instead, they must explicitly be set through the
|
For security reasons, authentication backends are *not* automatically discovered through a signal. Instead, they must
|
||||||
``auth_backends`` directive in the ``pretix.cfg`` :ref:`configuration file <config>`.
|
explicitly be set through the ``auth_backends`` directive in the ``pretix.cfg`` :ref:`configuration file <config>`.
|
||||||
|
|
||||||
In each of these methods (``form_authenticate``, ``request_authenticate`` or your custom view) you are supposed to
|
In each of these methods (``form_authenticate``, ``request_authenticate``, or your custom view) you are supposed to
|
||||||
either get an existing :py:class:`pretix.base.models.User` object from the database or create a new one. There are a
|
use ``User.objects.get_or_create_for_backend`` to get a :py:class:`pretix.base.models.User` object from the database
|
||||||
few rules you need to follow:
|
or create a new one.
|
||||||
|
|
||||||
* You **MUST** only return users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
|
There are a few rules you need to follow:
|
||||||
|
|
||||||
* You **MUST** create new users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
|
* You **MUST** have some kind of identifier for a user that is globally unique and **SHOULD** never change, even if the
|
||||||
|
user's name or email address changes. This could e.g. be the ID of the user in an external database. The identifier
|
||||||
|
must not be longer than 190 characters. If you worry your backend might generated longer identifiers, consider
|
||||||
|
using a hash function to trim them to a constant length.
|
||||||
|
|
||||||
|
* You **SHOULD** not allow users created by other authentication backends to log in through your code, and you **MUST**
|
||||||
|
only create, modify or return users with ``auth_backend`` set to your backend.
|
||||||
|
|
||||||
* Every user object **MUST** have an email address. Email addresses are globally unique. If the email address is
|
* Every user object **MUST** have an email address. Email addresses are globally unique. If the email address is
|
||||||
already registered to a user who signs in through a different backend, you **SHOULD** refuse the login.
|
already registered to a user who signs in through a different backend, you **SHOULD** refuse the login.
|
||||||
|
|
||||||
|
``User.objects.get_or_create_for_backend`` will follow these rules for you automatically. It works like this:
|
||||||
|
|
||||||
|
.. autoclass:: pretix.base.models.auth.UserManager
|
||||||
|
:members: get_or_create_for_backend
|
||||||
|
|
||||||
The backend interface
|
The backend interface
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
@@ -59,6 +70,7 @@ The backend interface
|
|||||||
|
|
||||||
.. automethod:: authentication_url
|
.. automethod:: authentication_url
|
||||||
|
|
||||||
|
|
||||||
Logging users in
|
Logging users in
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
@@ -68,3 +80,45 @@ recommend that you use the following utility method to correctly set session val
|
|||||||
authentication (if activated):
|
authentication (if activated):
|
||||||
|
|
||||||
.. autofunction:: pretix.control.views.auth.process_login
|
.. autofunction:: pretix.control.views.auth.process_login
|
||||||
|
|
||||||
|
A custom view that is called after a redirect from an external identity provider could look like this::
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from pretix.base.models import User
|
||||||
|
from pretix.base.models.auth import EmailAddressTakenError
|
||||||
|
from pretix.control.views.auth import process_login
|
||||||
|
|
||||||
|
|
||||||
|
def return_view(request):
|
||||||
|
# Verify validity of login with the external provider's API
|
||||||
|
api_response = my_verify_login_function(
|
||||||
|
code=request.GET.get('code')
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
u = User.objects.get_or_create_for_backend(
|
||||||
|
'my_backend_name',
|
||||||
|
api_response['userid'],
|
||||||
|
api_response['email'],
|
||||||
|
set_always={
|
||||||
|
'fullname': '{} {}'.format(
|
||||||
|
api_response.get('given_name', ''),
|
||||||
|
api_response.get('family_name', ''),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
set_on_creation={
|
||||||
|
'locale': api_response.get('locale').lower()[:2],
|
||||||
|
'timezone': api_response.get('zoneinfo', 'UTC'),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except EmailAddressTakenError:
|
||||||
|
messages.error(
|
||||||
|
request, _('We cannot create your user account as a user account in this system '
|
||||||
|
'already exists with the same email address.')
|
||||||
|
)
|
||||||
|
return redirect(reverse('control:auth.login'))
|
||||||
|
else:
|
||||||
|
return process_login(request, u, keep_logged_in=False)
|
||||||
|
|||||||
119
doc/development/api/cookieconsent.rst
Normal file
119
doc/development/api/cookieconsent.rst
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
.. highlight:: python
|
||||||
|
:linenothreshold: 5
|
||||||
|
|
||||||
|
.. _`cookieconsent`:
|
||||||
|
|
||||||
|
Handling cookie consent
|
||||||
|
=======================
|
||||||
|
|
||||||
|
pretix includes an optional feature to handle cookie consent explicitly to comply with EU regulations.
|
||||||
|
If your plugin sets non-essential cookies or includes a third-party service that does so, you should
|
||||||
|
integrate with this feature.
|
||||||
|
|
||||||
|
Server-side integration
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
First, you need to declare that you are using non-essential cookies by responding to the following
|
||||||
|
signal:
|
||||||
|
|
||||||
|
.. automodule:: pretix.presale.signals
|
||||||
|
:members: register_cookie_providers
|
||||||
|
|
||||||
|
You are expected to return a list of ``CookieProvider`` objects instantiated from the following class:
|
||||||
|
|
||||||
|
.. class:: pretix.presale.cookies.CookieProvider
|
||||||
|
|
||||||
|
.. py:attribute:: CookieProvider.identifier
|
||||||
|
|
||||||
|
A short and unique identifier used to distinguish this cookie provider form others (required).
|
||||||
|
|
||||||
|
.. py:attribute:: CookieProvider.provider_name
|
||||||
|
|
||||||
|
A human-readable name of the entity of feature responsible for setting the cookie (required).
|
||||||
|
|
||||||
|
.. py:attribute:: CookieProvider.usage_classes
|
||||||
|
|
||||||
|
A list of enum values from the ``pretix.presale.cookies.UsageClass`` enumeration class, such as
|
||||||
|
``UsageClass.ANALYTICS``, ``UsageClass.MARKETING``, or ``UsageClass.SOCIAL`` (required).
|
||||||
|
|
||||||
|
.. py:attribute:: CookieProvider.privacy_url
|
||||||
|
|
||||||
|
A link to a privacy policy (optional).
|
||||||
|
|
||||||
|
Here is an example of such a receiver:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
@receiver(register_cookie_providers)
|
||||||
|
def recv_cookie_providers(sender, request, **kwargs):
|
||||||
|
return [
|
||||||
|
CookieProvider(
|
||||||
|
identifier='google_analytics',
|
||||||
|
provider_name='Google Analytics',
|
||||||
|
usage_classes=[UsageClass.ANALYTICS],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
JavaScript-side integration
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
The server-side integration only causes the cookie provider to show up in the cookie dialog. You still
|
||||||
|
need to care about actually enforcing the consent state.
|
||||||
|
|
||||||
|
You can access the consent state through the ``window.pretix.cookie_consent`` variable. Whenever the
|
||||||
|
value changes, a ``pretix:cookie-consent:change`` event is fired on the ``document`` object.
|
||||||
|
|
||||||
|
The variable will generally have one of the following states:
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
================================================================ =====================================================
|
||||||
|
State Interpretation
|
||||||
|
================================================================ =====================================================
|
||||||
|
``pretix === undefined || pretix.cookie_consent === undefined`` Your JavaScript has loaded before the cookie consent
|
||||||
|
script. Wait for the event to be fired, then try again,
|
||||||
|
do not yet set a cookie.
|
||||||
|
``pretix.cookie_consent === null`` The cookie consent mechanism has not been enabled. This
|
||||||
|
usually means that you can set cookies however you like.
|
||||||
|
``pretix.cookie_consent[identifier] === undefined`` The cookie consent mechanism is loaded, but has no data
|
||||||
|
on your cookie yet, wait for the event to be fired, do not
|
||||||
|
yet set a cookie.
|
||||||
|
``pretix.cookie_consent[identifier] === true`` The user has consented to your cookie.
|
||||||
|
``pretix.cookie_consent[identifier] === false`` The user has actively rejected your cookie.
|
||||||
|
================================================================ =====================================================
|
||||||
|
|
||||||
|
If you are integrating e.g. a tracking provider with native cookie consent support such
|
||||||
|
as Facebook's Pixel, you can integrate it like this:
|
||||||
|
|
||||||
|
.. code-block:: javascript
|
||||||
|
|
||||||
|
var consent = (window.pretix || {}).cookie_consent;
|
||||||
|
if (consent !== null && !(consent || {}).facebook) {
|
||||||
|
fbq('consent', 'revoke');
|
||||||
|
}
|
||||||
|
fbq('init', ...);
|
||||||
|
document.addEventListener('pretix:cookie-consent:change', function (e) {
|
||||||
|
fbq('consent', (e.detail || {}).facebook ? 'grant' : 'revoke');
|
||||||
|
})
|
||||||
|
|
||||||
|
If you have a JavaScript function that you only want to load if consent for a specific ``identifier``
|
||||||
|
is given, you can wrap it like this:
|
||||||
|
|
||||||
|
.. code-block:: javascript
|
||||||
|
|
||||||
|
var consent_identifier = "youridentifier";
|
||||||
|
var consent = (window.pretix || {}).cookie_consent;
|
||||||
|
if (consent === null || (consent || {})[consent_identifier] === true) {
|
||||||
|
// Cookie consent tool is either disabled or consent is given
|
||||||
|
addScriptElement(src);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either cookie consent tool has not loaded yet or consent is not given
|
||||||
|
document.addEventListener('pretix:cookie-consent:change', function onChange(e) {
|
||||||
|
var consent = e.detail || {};
|
||||||
|
if (consent === null || consent[consent_identifier] === true) {
|
||||||
|
addScriptElement(src);
|
||||||
|
document.removeEventListener('pretix:cookie-consent:change', onChange);
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -17,6 +17,7 @@ Contents:
|
|||||||
shredder
|
shredder
|
||||||
import
|
import
|
||||||
customview
|
customview
|
||||||
|
cookieconsent
|
||||||
auth
|
auth
|
||||||
general
|
general
|
||||||
quality
|
quality
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ The provider class
|
|||||||
|
|
||||||
.. autoattribute:: public_name
|
.. autoattribute:: public_name
|
||||||
|
|
||||||
|
.. autoattribute:: confirm_button_name
|
||||||
|
|
||||||
.. autoattribute:: is_enabled
|
.. autoattribute:: is_enabled
|
||||||
|
|
||||||
.. autoattribute:: priority
|
.. autoattribute:: priority
|
||||||
|
|||||||
@@ -45,13 +45,17 @@ Attribute Type Description
|
|||||||
name string The human-readable name of your plugin
|
name string The human-readable name of your plugin
|
||||||
author string Your name
|
author string Your name
|
||||||
version string A human-readable version code of your plugin
|
version string A human-readable version code of your plugin
|
||||||
description string A more verbose description of what your plugin does.
|
description string A more verbose description of what your plugin does. May contain HTML.
|
||||||
category string Category of a plugin. Either one of ``"FEATURE"``, ``"PAYMENT"``,
|
category string Category of a plugin. Either one of ``"FEATURE"``, ``"PAYMENT"``,
|
||||||
``"INTEGRATION"``, ``"CUSTOMIZATION"``, ``"FORMAT"``, or ``"API"``,
|
``"INTEGRATION"``, ``"CUSTOMIZATION"``, ``"FORMAT"``, or ``"API"``,
|
||||||
or any other string.
|
or any other string.
|
||||||
|
picture string (optional) Path to a picture resolvable through the static file system.
|
||||||
|
featured boolean (optional) ``False`` by default, can promote a plugin if it's something many users will want, use carefully.
|
||||||
visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated.
|
visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated.
|
||||||
restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled
|
restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled
|
||||||
for an event by system administrators / superusers.
|
for an event by system administrators / superusers.
|
||||||
|
experimental boolean (optional) ``False`` by default, marks a plugin as an experimental feature in the plugins list.
|
||||||
|
picture string (optional) Path to a picture resolvable through the static file system.
|
||||||
compatibility string Specifier for compatible pretix versions.
|
compatibility string Specifier for compatible pretix versions.
|
||||||
================== ==================== ===========================================================
|
================== ==================== ===========================================================
|
||||||
|
|
||||||
@@ -74,8 +78,10 @@ A working example would be:
|
|||||||
name = _("PayPal")
|
name = _("PayPal")
|
||||||
author = _("the pretix team")
|
author = _("the pretix team")
|
||||||
version = '1.0.0'
|
version = '1.0.0'
|
||||||
category = 'PAYMENT
|
category = 'PAYMENT'
|
||||||
|
picture = 'pretix_paypal/paypal_logo.svg'
|
||||||
visible = True
|
visible = True
|
||||||
|
featured = False
|
||||||
restricted = False
|
restricted = False
|
||||||
description = _("This plugin allows you to receive payments via PayPal")
|
description = _("This plugin allows you to receive payments via PayPal")
|
||||||
compatibility = "pretix>=2.7.0"
|
compatibility = "pretix>=2.7.0"
|
||||||
@@ -92,6 +98,7 @@ those will be displayed but not block the plugin execution.
|
|||||||
|
|
||||||
The ``AppConfig`` class may implement a method ``is_available(event)`` that checks if a plugin
|
The ``AppConfig`` class may implement a method ``is_available(event)`` that checks if a plugin
|
||||||
is available for a specific event. If not, it will not be shown in the plugin list of that event.
|
is available for a specific event. If not, it will not be shown in the plugin list of that event.
|
||||||
|
You should not define ``is_available`` and ``restricted`` on the same plugin.
|
||||||
|
|
||||||
Plugin registration
|
Plugin registration
|
||||||
-------------------
|
-------------------
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ Variable Description
|
|||||||
``attendee_city`` City of the ticket holder's address (or empty)
|
``attendee_city`` City of the ticket holder's address (or empty)
|
||||||
``attendee_country`` Country code of the ticket holder's address (or empty)
|
``attendee_country`` Country code of the ticket holder's address (or empty)
|
||||||
``attendee_state`` State of the ticket holder's address (or empty)
|
``attendee_state`` State of the ticket holder's address (or empty)
|
||||||
``answer[XYZ]`` Answer to the custom question with identifier ``XYZ``
|
``answers[XYZ]`` Answer to the custom question with identifier ``XYZ``
|
||||||
``invoice_name`` Full name of the invoice address (or empty)
|
``invoice_name`` Full name of the invoice address (or empty)
|
||||||
``invoice_name_*`` Name parts of the invoice address, depending on configuration, e.g. ``invoice_name_given_name`` or ``invoice_name_family_name``
|
``invoice_name_*`` Name parts of the invoice address, depending on configuration, e.g. ``invoice_name_given_name`` or ``invoice_name_family_name``
|
||||||
``invoice_company`` Company of the invoice address (or empty)
|
``invoice_company`` Company of the invoice address (or empty)
|
||||||
|
|||||||
630
doc/plugins/exhibitors.rst
Normal file
630
doc/plugins/exhibitors.rst
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
Exhibitors
|
||||||
|
==========
|
||||||
|
|
||||||
|
The exhibitors plugin allows to manage exhibitors at your trade show or conference. After signing up your exhibitors
|
||||||
|
in the system, you can assign vouchers to exhibitors and give them access to the data of these vouchers. The exhibitors
|
||||||
|
module is also the basis of the pretixLEAD lead scanning application.
|
||||||
|
|
||||||
|
.. note:: On pretix Hosted, using the lead scanning feature of the exhibitors plugin can add additional costs
|
||||||
|
depending on your contract.
|
||||||
|
|
||||||
|
The plugin exposes two APIs. One (REST API) is intended for bulk-data operations from the admin side, and one
|
||||||
|
(App API) that is used by the pretixLEAD app.
|
||||||
|
|
||||||
|
REST API
|
||||||
|
---------
|
||||||
|
|
||||||
|
The REST API for exhibitors requires the usual :ref:`rest-auth`.
|
||||||
|
|
||||||
|
Resources
|
||||||
|
"""""""""
|
||||||
|
|
||||||
|
The exhibitors plugin provides a HTTP API that allows you to create new exhibitors.
|
||||||
|
|
||||||
|
The exhibitors resource contains the following public fields:
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
Field Type Description
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
id integer Internal exhibitor ID in pretix
|
||||||
|
name string Exhibitor name
|
||||||
|
internal_id string Can be used for the ID in your exhibition system, your customer ID, etc. Can be ``null``. Maximum 255 characters.
|
||||||
|
contact_name string Contact person (or ``null``)
|
||||||
|
contact_name_parts object of strings Decomposition of contact name (i.e. given name, family name)
|
||||||
|
contact_email string Contact person email address (or ``null``)
|
||||||
|
booth string Booth number (or ``null``). Maximum 100 characters.
|
||||||
|
locale string Locale for communication with the exhibitor (or ``null``).
|
||||||
|
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
|
||||||
|
allow_voucher_access boolean Enables access to data gathered by exhibitor vouchers
|
||||||
|
comment string Internal comment, not shown to exhibitor
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
You can also access the scanned leads through the API which contains the following public fields:
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
Field Type Description
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
attendee_order string Order code of the order the scanned attendee belongs to
|
||||||
|
attendee_positionid integer ``positionid`` if the attendee within the order specified by ``attendee_order``
|
||||||
|
rating integer A rating of 0 to 5 stars (or ``null``)
|
||||||
|
notes string A note taken by the exhibitor after scanning
|
||||||
|
tags list of strings Additional tags selected by the exhibitor
|
||||||
|
first_upload datetime Date and time of the first upload of this lead
|
||||||
|
data list of objects Attendee data set that may be shown to the exhibitor based o
|
||||||
|
the event's configuration. Each entry contains the fields ``id``,
|
||||||
|
``label``, ``value``, and ``details``. ``details`` is usually empty
|
||||||
|
except in a few cases where it contains an additional list of objects
|
||||||
|
with ``value`` and ``label`` keys (e.g. splitting of names).
|
||||||
|
device_name string User-defined name for the device used for scanning (or ``null``).
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
"""""""""
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/
|
||||||
|
|
||||||
|
Returns a list of all exhibitors configured for an event.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/exhibitors/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Aperture Science",
|
||||||
|
"internal_id": null,
|
||||||
|
"contact_name": "Dr Cave Johnson",
|
||||||
|
"contact_name_parts": {
|
||||||
|
"_scheme": "salutation_title_given_family",
|
||||||
|
"family_name": "Johnson",
|
||||||
|
"given_name": "Cave",
|
||||||
|
"salutation": "",
|
||||||
|
"title": "Dr"
|
||||||
|
},
|
||||||
|
"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,
|
||||||
|
"comment": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:query page: The page number in case of a multi-page result set, default is 1
|
||||||
|
:param organizer: The ``slug`` field of a valid organizer
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to view it.
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/
|
||||||
|
|
||||||
|
Returns information on one exhibitor, identified by its ID.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Aperture Science",
|
||||||
|
"internal_id": null,
|
||||||
|
"contact_name": "Dr Cave Johnson",
|
||||||
|
"contact_name_parts": {
|
||||||
|
"_scheme": "salutation_title_given_family",
|
||||||
|
"family_name": "Johnson",
|
||||||
|
"given_name": "Cave",
|
||||||
|
"salutation": "",
|
||||||
|
"title": "Dr"
|
||||||
|
},
|
||||||
|
"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,
|
||||||
|
"comment": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:param id: The ``id`` field of the exhibitor to fetch
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event/exhibitor does not exist **or** you have no permission to view it.
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/leads/
|
||||||
|
|
||||||
|
Returns a list of all scanned leads of an exhibitor.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/leads/ 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": [
|
||||||
|
{
|
||||||
|
"attendee_order": "T0E7E",
|
||||||
|
"attendee_positionid": 1,
|
||||||
|
"rating": 1,
|
||||||
|
"notes": "",
|
||||||
|
"tags": [],
|
||||||
|
"first_upload": "2021-07-06T11:03:31.414491+01:00",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "attendee_name",
|
||||||
|
"label": "Attendee name",
|
||||||
|
"value": "Peter Miller",
|
||||||
|
"details": [
|
||||||
|
{"label": "Given name", "value": "Peter"},
|
||||||
|
{"label": "Family name", "value": "Miller"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:query page: The page number in case of a multi-page result set, default is 1
|
||||||
|
:param organizer: The ``slug`` field of a valid organizer
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:param id: The ``id`` field of the exhibitor to fetch
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer or event or exhibitor does not exist **or** you have no permission to view it.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/
|
||||||
|
|
||||||
|
Create a new exhibitor.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/exhibitors/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 166
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Aperture Science",
|
||||||
|
"internal_id": null,
|
||||||
|
"contact_name_parts": {
|
||||||
|
"_scheme": "salutation_title_given_family",
|
||||||
|
"family_name": "Johnson",
|
||||||
|
"given_name": "Cave",
|
||||||
|
"salutation": "",
|
||||||
|
"title": "Dr"
|
||||||
|
},
|
||||||
|
"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,
|
||||||
|
"comment": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 201 Created
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Aperture Science",
|
||||||
|
"internal_id": null,
|
||||||
|
"contact_name": "Dr Cave Johnson",
|
||||||
|
"contact_name_parts": {
|
||||||
|
"_scheme": "salutation_title_given_family",
|
||||||
|
"family_name": "Johnson",
|
||||||
|
"given_name": "Cave",
|
||||||
|
"salutation": "",
|
||||||
|
"title": "Dr"
|
||||||
|
},
|
||||||
|
"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,
|
||||||
|
"comment": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to create new exhibitor for
|
||||||
|
:param event: The ``slug`` field of the event to create new exhibitor for
|
||||||
|
:statuscode 201: no error
|
||||||
|
:statuscode 400: The exhibitor could not be created due to invalid submitted data.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create exhibitors.
|
||||||
|
|
||||||
|
|
||||||
|
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/
|
||||||
|
|
||||||
|
Update an exhibitor. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||||
|
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||||
|
want to change.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
PATCH /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/1/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 34
|
||||||
|
|
||||||
|
{
|
||||||
|
"internal_id": "ABC"
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: text/javascript
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Aperture Science",
|
||||||
|
"internal_id": "ABC",
|
||||||
|
"contact_name": "Dr Cave Johnson",
|
||||||
|
"contact_name_parts": {
|
||||||
|
"_scheme": "salutation_title_given_family",
|
||||||
|
"family_name": "Johnson",
|
||||||
|
"given_name": "Cave",
|
||||||
|
"salutation": "",
|
||||||
|
"title": "Dr"
|
||||||
|
},
|
||||||
|
"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,
|
||||||
|
"comment": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to modify
|
||||||
|
:param event: The ``slug`` field of the event to modify
|
||||||
|
:param id: The ``id`` field of the exhibitor to modify
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: The exhibitor could not be modified due to invalid submitted data.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event/exhibitor does not exist **or** you have no permission to change it.
|
||||||
|
|
||||||
|
|
||||||
|
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/
|
||||||
|
|
||||||
|
Delete an exhibitor.
|
||||||
|
|
||||||
|
.. warning:: This deletes all lead scan data and removes all connections to vouchers (the vouchers are not deleted).
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
DELETE /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 204 No Content
|
||||||
|
Vary: Accept
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to modify
|
||||||
|
:param event: The ``slug`` field of the event to modify
|
||||||
|
:param id: The ``id`` field of the exhibitor to delete
|
||||||
|
:statuscode 204: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event/exhibitor does not exist **or** you have no permission to change it
|
||||||
|
|
||||||
|
|
||||||
|
App API
|
||||||
|
-------
|
||||||
|
|
||||||
|
The App API is used for communication between the pretixLEAD app and the pretix server.
|
||||||
|
|
||||||
|
.. warning:: We consider this an internal API, it is not intended for external use. You may still use it, but
|
||||||
|
our :ref:`compatibility commitment <rest-compat>` does not apply.
|
||||||
|
|
||||||
|
Authentication
|
||||||
|
""""""""""""""
|
||||||
|
|
||||||
|
Every exhibitor has an "access code", usually consisting of 8 alphanumeric uppercase characters.
|
||||||
|
This access code is communicated to event exhibitors by the event organizers, so this is also what
|
||||||
|
exhibitors should enter into a login screen.
|
||||||
|
|
||||||
|
All API requests need to contain this access code as a header like this::
|
||||||
|
|
||||||
|
Authorization: Exhibitor ABCDE123
|
||||||
|
|
||||||
|
Exhibitor profile
|
||||||
|
"""""""""""""""""
|
||||||
|
|
||||||
|
Upon login and in regular intervals after that, the API should fetch the exhibitors profile.
|
||||||
|
This serves two purposes:
|
||||||
|
|
||||||
|
* Checking if the authorization code is actually valid
|
||||||
|
|
||||||
|
* Obtaining information that can be shown in the app
|
||||||
|
|
||||||
|
The resource consists of the following fields:
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
Field Type Description
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
name string Exhibitor name
|
||||||
|
booth string Booth number (or ``null``)
|
||||||
|
event object Object describing the event
|
||||||
|
├ name multi-lingual string Event name
|
||||||
|
├ imprint_url string URL to legal notice page. If not ``null``, a button in the app should link to this page.
|
||||||
|
├ privacy_url string URL to privacy notice page. If not ``null``, a button in the app should link to this page.
|
||||||
|
├ help_url string URL to help page. If not ``null``, a button in the app should link to this page.
|
||||||
|
├ logo_url string URL to event logo. If not ``null``, this logo may be shown in the app.
|
||||||
|
├ slug string Event short form
|
||||||
|
└ organizer string Organizer short form
|
||||||
|
notes boolean Specifies whether the exhibitor is allowed to take notes on leads
|
||||||
|
tags list of strings List of tags the exhibitor can assign to their leads
|
||||||
|
scan_types list of objects Only used for a special case, fixed value that external API consumers should ignore
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
.. http:get:: /exhibitors/api/v1/profile
|
||||||
|
|
||||||
|
**Example request:**
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /exhibitors/api/v1/profile HTTP/1.1
|
||||||
|
Authorization: Exhibitor ABCDE123
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response:**
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Aperture Science",
|
||||||
|
"booth": "A2",
|
||||||
|
"event": {
|
||||||
|
"name": {"en": "Sample conference", "de": "Beispielkonferenz"},
|
||||||
|
"slug": "bigevents",
|
||||||
|
"imprint_url": null,
|
||||||
|
"privacy_url": null,
|
||||||
|
"help_url": null,
|
||||||
|
"logo_url": null,
|
||||||
|
"organizer": "sampleconf"
|
||||||
|
},
|
||||||
|
"notes": true,
|
||||||
|
"tags": ["foo", "bar"],
|
||||||
|
"scan_types": [
|
||||||
|
{
|
||||||
|
"key": "lead",
|
||||||
|
"label": "Lead Scanning"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 401: Invalid authentication code
|
||||||
|
|
||||||
|
Submitting a lead
|
||||||
|
"""""""""""""""""
|
||||||
|
|
||||||
|
After a ticket/badge is scanned, it should immediately be submitted to the server
|
||||||
|
so the scan is stored and information about the person can be shown in the app. The same
|
||||||
|
code can be submitted multiple times, so it's no problem to just submit it again after the
|
||||||
|
exhibitor set a note or a rating (0-5) inside the app.
|
||||||
|
|
||||||
|
On the request, you should set the following properties:
|
||||||
|
|
||||||
|
* ``code`` with the scanned barcode
|
||||||
|
* ``notes`` with the exhibitor's notes
|
||||||
|
* ``scanned`` with the date and time of the actual scan (not the time of the upload)
|
||||||
|
* ``scan_type`` set to ``lead`` statically
|
||||||
|
* ``tags`` with the list of selected tags
|
||||||
|
* ``rating`` with the rating assigned by the exhibitor
|
||||||
|
* ``device_name`` with a user-specified name of the device used for scanning (max. 190 characters), or ``null``
|
||||||
|
|
||||||
|
If you submit ``tags`` and ``rating`` to be ``null`` and ``notes`` to be ``""``, the server
|
||||||
|
responds with the previously saved information and will not delete that information. If you
|
||||||
|
supply other values, the information saved on the server will be overridden.
|
||||||
|
|
||||||
|
The response will also contain ``tags``, ``rating``, and ``notes``. Additionally,
|
||||||
|
it will include ``attendee`` with a list of ``fields`` that can be shown to the
|
||||||
|
user. Each field has an internal ``id``, a human-readable ``label``, and a ``value`` (all strings).
|
||||||
|
|
||||||
|
Note that the ``fields`` array can contain any number of dynamic keys!
|
||||||
|
Depending on the exhibitors permission and event configuration this might be empty,
|
||||||
|
or contain lots of details. The app should dynamically show these values (read-only)
|
||||||
|
with the labels sent by the server.
|
||||||
|
|
||||||
|
The request for this looks like this:
|
||||||
|
|
||||||
|
.. http:post:: /exhibitors/api/v1/leads/
|
||||||
|
|
||||||
|
**Example request:**
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /exhibitors/api/v1/leads/ HTTP/1.1
|
||||||
|
Authorization: Exhibitor ABCDE123
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "qrcodecontent",
|
||||||
|
"notes": "Great customer, wants our newsletter",
|
||||||
|
"scanned": "2020-10-18T12:24:23.000+00:00",
|
||||||
|
"scan_type": "lead",
|
||||||
|
"tags": ["foo"],
|
||||||
|
"rating": 4,
|
||||||
|
"device_name": "DEV1"
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response:**
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 201 Created
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"attendee": {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "attendee_name",
|
||||||
|
"label": "Name",
|
||||||
|
"value": "Jon Doe",
|
||||||
|
"details": [
|
||||||
|
{"label": "Given name", "value": "John"},
|
||||||
|
{"label": "Family name", "value": "Doe"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "attendee_email",
|
||||||
|
"label": "Email",
|
||||||
|
"value": "test@example.com",
|
||||||
|
"details": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rating": 4,
|
||||||
|
"tags": ["foo"],
|
||||||
|
"notes": "Great customer, wants our newsletter"
|
||||||
|
}
|
||||||
|
|
||||||
|
:statuscode 200: No error, leads was not scanned for the first time
|
||||||
|
:statuscode 201: No error, leads was scanned for the first time
|
||||||
|
:statuscode 400: Invalid data submitted
|
||||||
|
:statuscode 401: Invalid authentication code
|
||||||
|
|
||||||
|
You can also fetch existing leads (if you are authorized to do so):
|
||||||
|
|
||||||
|
.. http:get:: /exhibitors/api/v1/leads/
|
||||||
|
|
||||||
|
**Example request:**
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /exhibitors/api/v1/leads/ HTTP/1.1
|
||||||
|
Authorization: Exhibitor ABCDE123
|
||||||
|
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": [
|
||||||
|
{
|
||||||
|
"attendee": {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "attendee_name",
|
||||||
|
"label": "Name",
|
||||||
|
"value": "Jon Doe",
|
||||||
|
"details": [
|
||||||
|
{"label": "Given name", "value": "John"},
|
||||||
|
{"label": "Family name", "value": "Doe"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "attendee_email",
|
||||||
|
"label": "Email",
|
||||||
|
"value": "test@example.com",
|
||||||
|
"details": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rating": 4,
|
||||||
|
"tags": ["foo"],
|
||||||
|
"notes": "Great customer, wants our newsletter"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:statuscode 200: No error
|
||||||
|
:statuscode 401: Invalid authentication code
|
||||||
|
:statuscode 403: Not permitted to access bulk data
|
||||||
301
doc/plugins/imported_secrets.rst
Normal file
301
doc/plugins/imported_secrets.rst
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
Secrets Import
|
||||||
|
==============
|
||||||
|
|
||||||
|
Usually, pretix generates ticket secrets (i.e. the QR code used for scanning) itself. You can read more about this
|
||||||
|
process at :ref:`secret_generators`.
|
||||||
|
|
||||||
|
With the "Secrets Import" plugin, you can upload your own list of secrets to be used instead. This is useful for
|
||||||
|
integrating with third-party check-in systems.
|
||||||
|
|
||||||
|
|
||||||
|
API Resource description
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
The secrets import plugin provides a HTTP API that allows you to create new secrets.
|
||||||
|
|
||||||
|
The imported secret resource contains the following public fields:
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
Field Type Description
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
id integer Internal ID of the secret
|
||||||
|
secret string Actual string content of the secret (QR code content)
|
||||||
|
used boolean Whether the secret was already used for a ticket. If ``true``,
|
||||||
|
the secret can no longer be deleted. Secrets are never used
|
||||||
|
twice, even if an order is canceled or deleted.
|
||||||
|
item integer Internal ID of a product, or ``null``. If set, the secret
|
||||||
|
will only be used for tickets of this product.
|
||||||
|
variation integer Internal ID of a product variation, or ``null``. If set, the secret
|
||||||
|
will only be used for tickets of this product variation.
|
||||||
|
subevent integer Internal ID of an event series date, or ``null``. If set, the secret
|
||||||
|
will only be used for tickets of this event series date.
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
API Endpoints
|
||||||
|
-------------
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/
|
||||||
|
|
||||||
|
Returns a list of all secrets imported for an event.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"secret": "foobar",
|
||||||
|
"used": false,
|
||||||
|
"item": null,
|
||||||
|
"variation": null,
|
||||||
|
"subevent": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:query page: The page number in case of a multi-page result set, default is 1
|
||||||
|
:param organizer: The ``slug`` field of a valid organizer
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to view it.
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/(id)/
|
||||||
|
|
||||||
|
Returns information on one secret, identified by its ID.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/1/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"secret": "foobar",
|
||||||
|
"used": false,
|
||||||
|
"item": null,
|
||||||
|
"variation": null,
|
||||||
|
"subevent": null
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:param id: The ``id`` field of the secret to fetch
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event/secret does not exist **or** you have no permission to view it.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/
|
||||||
|
|
||||||
|
Create a new secret.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 166
|
||||||
|
|
||||||
|
{
|
||||||
|
"secret": "foobar",
|
||||||
|
"used": false,
|
||||||
|
"item": null,
|
||||||
|
"variation": null,
|
||||||
|
"subevent": null
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 201 Created
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"secret": "foobar",
|
||||||
|
"used": false,
|
||||||
|
"item": null,
|
||||||
|
"variation": null,
|
||||||
|
"subevent": null
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to a create new secret for
|
||||||
|
:param event: The ``slug`` field of the event to create a new secret for
|
||||||
|
:statuscode 201: no error
|
||||||
|
:statuscode 400: The secret could not be created due to invalid submitted data.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create secrets.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/bulk_create/
|
||||||
|
|
||||||
|
Create new secrets in bulk (up to 500 per request). The request either succeeds or fails entirely.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/bulk_create/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 166
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"secret": "foobar",
|
||||||
|
"used": false,
|
||||||
|
"item": null,
|
||||||
|
"variation": null,
|
||||||
|
"subevent": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"secret": "baz",
|
||||||
|
"used": false,
|
||||||
|
"item": null,
|
||||||
|
"variation": null,
|
||||||
|
"subevent": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"secret": "foobar",
|
||||||
|
"used": false,
|
||||||
|
"item": null,
|
||||||
|
"variation": null,
|
||||||
|
"subevent": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"secret": "baz",
|
||||||
|
"used": false,
|
||||||
|
"item": null,
|
||||||
|
"variation": null,
|
||||||
|
"subevent": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to create new secrets for
|
||||||
|
:param event: The ``slug`` field of the event to create new secrets for
|
||||||
|
:statuscode 201: no error
|
||||||
|
:statuscode 400: The secrets could not be created due to invalid submitted data.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create secrets.
|
||||||
|
|
||||||
|
|
||||||
|
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/(id)/
|
||||||
|
|
||||||
|
Update a secret. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||||
|
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||||
|
want to change.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
PATCH /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/1/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 34
|
||||||
|
|
||||||
|
{
|
||||||
|
"item": 2
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: text/javascript
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"secret": "foobar",
|
||||||
|
"used": false,
|
||||||
|
"item": 2,
|
||||||
|
"variation": null,
|
||||||
|
"subevent": null
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to modify
|
||||||
|
:param event: The ``slug`` field of the event to modify
|
||||||
|
:param id: The ``id`` field of the secret to modify
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: The secret could not be modified due to invalid submitted data.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event/secret does not exist **or** you have no permission to change it.
|
||||||
|
|
||||||
|
|
||||||
|
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/(id)/
|
||||||
|
|
||||||
|
Delete a secret. You can only delete secrets that have not yet been used.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
DELETE /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/1/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 204 No Content
|
||||||
|
Vary: Accept
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to modify
|
||||||
|
:param event: The ``slug`` field of the event to modify
|
||||||
|
:param id: The ``id`` field of the secret to delete
|
||||||
|
:statuscode 204: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event/secret does not exist **or** you have no permission to change it **or** the secret has already been used
|
||||||
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
.. _`plugin-docs`:
|
||||||
|
|
||||||
Plugin documentation
|
Plugin documentation
|
||||||
====================
|
====================
|
||||||
|
|
||||||
@@ -10,11 +12,13 @@ If you want to **create** a plugin, please go to the
|
|||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
list
|
list
|
||||||
pretixdroid
|
|
||||||
banktransfer
|
banktransfer
|
||||||
ticketoutputpdf
|
ticketoutputpdf
|
||||||
badges
|
badges
|
||||||
campaigns
|
campaigns
|
||||||
certificates
|
certificates
|
||||||
digital
|
digital
|
||||||
|
exhibitors
|
||||||
|
imported_secrets
|
||||||
webinar
|
webinar
|
||||||
|
presale-saml
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ List of plugins
|
|||||||
===============
|
===============
|
||||||
|
|
||||||
A detailed list of plugins that are available for pretix can be found on the
|
A detailed list of plugins that are available for pretix can be found on the
|
||||||
`project website`_.
|
`pretix Marketplace`_.
|
||||||
|
|
||||||
.. _project website: https://pretix.eu/about/en/plugins
|
.. _pretix Marketplace: https://marketplace.pretix.eu
|
||||||
|
|||||||
405
doc/plugins/presale-saml.rst
Normal file
405
doc/plugins/presale-saml.rst
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
.. highlight:: ini
|
||||||
|
.. spelling::
|
||||||
|
|
||||||
|
IdP
|
||||||
|
skIDentity
|
||||||
|
ePA
|
||||||
|
NPA
|
||||||
|
|
||||||
|
Presale SAML Authentication
|
||||||
|
===========================
|
||||||
|
|
||||||
|
The Presale SAML Authentication plugin is an advanced plugin, which most event
|
||||||
|
organizers will not need to use. However, for the select few who do require
|
||||||
|
strong customer authentication that cannot be covered by the built-in customer
|
||||||
|
account functionality, this plugin allows pretix to connect to a SAML IdP and
|
||||||
|
perform authentication and retrieval of user information.
|
||||||
|
|
||||||
|
Usage of the plugin is governed by two separate sets of settings: The plugin
|
||||||
|
installation, the Service Provider (SP) configuration and the event
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
Plugin installation and initial configuration
|
||||||
|
---------------------------------------------
|
||||||
|
|
||||||
|
.. note:: If you are a customer of our hosted `pretix.eu`_ offering, you can
|
||||||
|
skip this section.
|
||||||
|
|
||||||
|
The plugin is installed as any other plugin in the pretix ecosystem. As a
|
||||||
|
pretix system administrator, please follow the instructions in the the
|
||||||
|
:ref:`Administrator documentation <admindocs>`.
|
||||||
|
|
||||||
|
Once installed, you will need to assess, if you want (or need) your pretix
|
||||||
|
instance to be a single SP for all organizers and events or if every event
|
||||||
|
organizer has to provide their own SP.
|
||||||
|
|
||||||
|
Take the example of a university which runs pretix under an pretix Enterprise
|
||||||
|
agreement. Since they only provide ticketing services to themselves (every
|
||||||
|
organizer is still just a different department of the same university), a
|
||||||
|
single SP should be enough.
|
||||||
|
|
||||||
|
On the other hand, a reseller such as `pretix.eu`_ who services a multitude
|
||||||
|
of clients would not work that way. Here, every organizer is a separate
|
||||||
|
legal entity and as such will also need to provide their own SP configuration:
|
||||||
|
Company A will expect their SP to reflect their company - and not a generalized
|
||||||
|
"pretix SP".
|
||||||
|
|
||||||
|
Once you have decided on the mode of operation, the :ref:`Configuration file
|
||||||
|
<config>` needs to be extended to reflect your choice.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
[presale-saml]
|
||||||
|
level=global
|
||||||
|
|
||||||
|
``level``
|
||||||
|
``global`` to use only a single, system-wide SP, ``organizer`` for multiple
|
||||||
|
SPs, configured on the organizer-level. Defaults to ``organizer``.
|
||||||
|
|
||||||
|
Service Provider configuration
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
Global Level
|
||||||
|
^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. note:: If you are a customer of our hosted `pretix.eu`_ offering, you can
|
||||||
|
skip this section and follow the instructions on the upcoming
|
||||||
|
Organizer Level settings.
|
||||||
|
|
||||||
|
As a user with administrative privileges, please activate them by clicking the
|
||||||
|
`Admin Mode` button in the top right hand corner.
|
||||||
|
|
||||||
|
You should now see a new menu-item titled `SAML` appear.
|
||||||
|
|
||||||
|
Organizer Level
|
||||||
|
^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Navigate to the organizer settings in the pretix backend. In the navigation
|
||||||
|
bar, you will find a menu-item titled `SAML` if your user has the `Can
|
||||||
|
change organizer settings` permission.
|
||||||
|
|
||||||
|
|
||||||
|
.. note:: If you are a customer of our hosted `pretix.eu`_ offering, the menu
|
||||||
|
will only appear once one of our friendly customer service agents
|
||||||
|
has enabled the Presale SAML Authentication plugin for at least one
|
||||||
|
of your events. Feel free to get in touch with us!
|
||||||
|
|
||||||
|
Setting up the SP
|
||||||
|
^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
No matter where your SP configuration lives, you will be greeted by a very
|
||||||
|
long list of fields of which almost all of them will need to be filled. Please
|
||||||
|
don't be discouraged - most of the settings don't need to be decided by yourself
|
||||||
|
and/or are already preset with a sensible default setting.
|
||||||
|
|
||||||
|
If you are not sure what setting you should choose for any of the fields, you
|
||||||
|
should reach out to your IdP operator as they can tell you exactly what the IdP
|
||||||
|
expects and - more importantly - supports.
|
||||||
|
|
||||||
|
``IdP Metadata URL``
|
||||||
|
Please provide the URL where your IdP outputs its metadata. For most IdPs,
|
||||||
|
this URL is static and the same for all SPs. If you are a member of the
|
||||||
|
DFN-AAI, you can find the meta-data for the `Test-, Basic- and
|
||||||
|
Advanced-Federation`_ on their website. Please do talk with your local
|
||||||
|
IdP operator though, as you might not even need to go through the DFN-AAI
|
||||||
|
and might just use your institutions local IdP which will also host their
|
||||||
|
metadata on a different URL.
|
||||||
|
|
||||||
|
The URL needs to be publicly accessible, as saving the settings form will
|
||||||
|
fail if the IdP metadata cannot be retrieved. pretix will also automatically
|
||||||
|
refresh the IdP metadata on a regular basis.
|
||||||
|
|
||||||
|
``SP Entity Id``
|
||||||
|
By default, we recommend that you use the system-proposed metadata-URL as
|
||||||
|
the Entity Id of your SP. However, if so desired or required by your IdP,
|
||||||
|
you can also set any other, arbitrary URL as the SP Entity Id.
|
||||||
|
|
||||||
|
``SP Name / SP Decription``
|
||||||
|
Most IdP will display the name and description of your SP to the users
|
||||||
|
during authentication. The description field can be used to explain to the
|
||||||
|
users how their data is being used.
|
||||||
|
|
||||||
|
``SP X.509 Certificate / SP X.509 Private Key``
|
||||||
|
Your SP needs a certificate and a private key for said certificate. Please
|
||||||
|
coordinate with your IdP, if you are supposed to generate these yourself or
|
||||||
|
if they are provided to you.
|
||||||
|
|
||||||
|
``SP X.509 New Certificate``
|
||||||
|
As certificates have an expiry date, they need to be renewed on a regular
|
||||||
|
basis. In order to facilitate the rollover from the expiring to the new
|
||||||
|
certificate, you can provide the new certificate already before the expiration
|
||||||
|
of the existing one. That way, the system will automatically use the correct
|
||||||
|
one. Once the old certificate has expired and is not used anymore at all,
|
||||||
|
you can move the new certificate into the slot of the normal certificate and
|
||||||
|
keep the new slot empty for your next renewal process.
|
||||||
|
|
||||||
|
``Requested Attributes``
|
||||||
|
An IdP can hold a variety of attributes of an authenticating user. While
|
||||||
|
your IdP will dictate which of the available attributes your SP can consume
|
||||||
|
in theory, you will still need to define exactly which attributes the SP
|
||||||
|
should request.
|
||||||
|
|
||||||
|
The notation is a JSON list of objects with 5 attributes each:
|
||||||
|
|
||||||
|
* ``attributeValue``: Can be defaulted to ``[]``.
|
||||||
|
* ``friendlyName``: String used in the upcoming event-level settings to
|
||||||
|
retrieve the attributes data.
|
||||||
|
* ``isRequired``: Boolean indicating whether the IdP must enforce the
|
||||||
|
transmission of this attribute. In most cases, ``true`` is the best
|
||||||
|
choice.
|
||||||
|
* ``name``: String of the internal, technical name of the requested
|
||||||
|
attribute. Often starting with ``urn:mace:dir:attribute-def:``,
|
||||||
|
``urn:oid:`` or ``http://``/``https://``.
|
||||||
|
* ``nameFormat``: String describing the type of ``name`` that has been
|
||||||
|
set in the previous section. Often starting with
|
||||||
|
``urn:mace:shibboleth:1.0:`` or ``urn:oasis:names:tc:SAML:2.0:``.
|
||||||
|
|
||||||
|
Your IdP can provide you with a list of available attributes. See below
|
||||||
|
for a sample configuration in an academic context.
|
||||||
|
|
||||||
|
Note, that you can have multiple attributes with the same ``friendlyName``
|
||||||
|
but different ``name``s. This is often used in systems, where the same
|
||||||
|
information (for example a persons name) is saved in different fields -
|
||||||
|
for example because one institution is returning SAML 1.0 and other
|
||||||
|
institutions are returning SAML 2.0 style attributes. Typically, this only
|
||||||
|
occurs in mix environments like the DFN-AAI with a large number of
|
||||||
|
participants. If you are only using your own institutions IdP and not
|
||||||
|
authenticating anyone outside of your realm, this should not be a common
|
||||||
|
sight.
|
||||||
|
|
||||||
|
``Encrypt/Sign/Require ...``
|
||||||
|
Does what is says on the box - please inquire with your IdP for the
|
||||||
|
necessary settings. Most settings can be turned on as they increase security,
|
||||||
|
however some IdPs might stumble over some of them.
|
||||||
|
|
||||||
|
``Signature / Digest Algorithm``
|
||||||
|
Please chose appropriate algorithms, that both pretix/your SP and the IdP
|
||||||
|
can communicate with. A common source of issues when connecting to a
|
||||||
|
Shibboleth-based IdP is the Digest Algorithm: pretix does not support
|
||||||
|
``http://www.w3.org/2009/xmlenc11#rsa-oaep`` and authentication will fail
|
||||||
|
if the IdP enforces this.
|
||||||
|
|
||||||
|
``Technical/Support Contacts``
|
||||||
|
Those contacts are encoded into the SPs public meta data and might be
|
||||||
|
displayed to users having trouble authenticating. It is recommended to
|
||||||
|
provide a dedicated point of contact for technical issues, as those will
|
||||||
|
be the ones to change the configuration for the SP.
|
||||||
|
|
||||||
|
Event / Authentication configuration
|
||||||
|
------------------------------------
|
||||||
|
|
||||||
|
Basic settings
|
||||||
|
^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Once the plugin has been enabled for a pretix event using the Plugins-menu from
|
||||||
|
the event's settings, a new *SAML* menu item will show up.
|
||||||
|
|
||||||
|
On this page, the actual authentication can be configured.
|
||||||
|
|
||||||
|
``Checkout Explanation``
|
||||||
|
Since most users probably won't be familiar with why they have to authenticate
|
||||||
|
to buy a ticket, you can provide them a small blurb here. Markdown is supported.
|
||||||
|
|
||||||
|
``Attribute RegEx``
|
||||||
|
By default, any successful authentication with the IdP will allow the user to
|
||||||
|
proceed with their purchase. Should the allowed audience needed to be restricted
|
||||||
|
further, a set of regular Expressions can be used to do this.
|
||||||
|
|
||||||
|
An Attribute RegEx of ``{}`` will allow any authenticated user to pass.
|
||||||
|
|
||||||
|
A RegEx of ``{ "affiliation": "^(employee@pretix.eu|staff@pretix.eu)$" }`` will
|
||||||
|
only allow user to pass which have the ``affiliation`` attribute and whose
|
||||||
|
attribute either matches ``employee@pretix.eu`` or ``staff@pretix.eu``.
|
||||||
|
|
||||||
|
Please make sure that the attribute you are querying is also requested from the
|
||||||
|
IdP in the first place - for a quick check you can have a look at the top of
|
||||||
|
the page where all currently configured attributes are listed.
|
||||||
|
|
||||||
|
``RegEx Fail Explanation``
|
||||||
|
Only used in conjunction with the above Attribute RegEx. Should the user not
|
||||||
|
pass the restrictions imposed by the regular expression, the user is shown
|
||||||
|
this error-message.
|
||||||
|
|
||||||
|
If you are - for example in an university context - restricting access to
|
||||||
|
students only, you might want to explain here that Employees are not allowed
|
||||||
|
to book tickets.
|
||||||
|
|
||||||
|
``Ticket Secret SAML Attribute``
|
||||||
|
In very specific instances, it might be desirable that the ticket-secret is
|
||||||
|
not the randomly one generated by pretix but rather based on one of the
|
||||||
|
users attributes - for example their unique ID or access card number.
|
||||||
|
|
||||||
|
To achieve this, the name of a SAML-attribute can be specified here.
|
||||||
|
|
||||||
|
It is however necessary to note, that even with this setting in use,
|
||||||
|
ticket-secrets need to be unique. This is why when this setting is enabled,
|
||||||
|
the default, pretix-generated ticket-secret is prefixed with the attributes
|
||||||
|
value.
|
||||||
|
|
||||||
|
Example: A users ``cardid`` attribute has the value of ``01189998819991197253``.
|
||||||
|
The default random ticket secret would have been
|
||||||
|
``yczygpw9877akz2xwdhtdyvdqwkv7npj``. The resulting new secret will now be
|
||||||
|
``01189998819991197253_yczygpw9877akz2xwdhtdyvdqwkv7npj``.
|
||||||
|
|
||||||
|
That way, the ticket secret is still unique, but when checking into an event,
|
||||||
|
the user can easily be searched and found using their identifier.
|
||||||
|
|
||||||
|
IdP-provided E-Mail addresses, names
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
By default, pretix will only authenticate the user and not process the received
|
||||||
|
data any further.
|
||||||
|
|
||||||
|
However, there are a few exceptions to this rule.
|
||||||
|
|
||||||
|
There are a few `magic` attributes that pretix will use to automatically populate
|
||||||
|
the corresponding fields within the checkout process **and lock them out from
|
||||||
|
user editing**.
|
||||||
|
|
||||||
|
* ``givenName`` and ``sn``: If both of those attributes are present and pretix
|
||||||
|
is configured to collect the users name, these attributes' values are used
|
||||||
|
for the given and family name respectively.
|
||||||
|
* ``email``: If this attribute is present, the E-Mail-address of the users will
|
||||||
|
be set to the one transmitted through the attributes.
|
||||||
|
|
||||||
|
The latter might pose a problem, if the IdP is transmitting an ``email`` attribute
|
||||||
|
which does contain a system-level mail address which is only used as an internal
|
||||||
|
identifier but not as a real mailbox. In this case, please consider setting the
|
||||||
|
``friendlyName`` of the attribute to a different value than ``email`` or removing
|
||||||
|
this field from the list of requested attributes altogether.
|
||||||
|
|
||||||
|
Saving attributes to questions
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
By setting the ``internal identifier`` of a user-defined question to the same name
|
||||||
|
as a SAML attribute, pretix will save the value of said attribute into the question.
|
||||||
|
|
||||||
|
All the same as in the above section on E-Mail addresses, those fields become
|
||||||
|
non-editable by the user.
|
||||||
|
|
||||||
|
Please be aware that some specialty question types might not be compatible with
|
||||||
|
the SAML attributes due to specific format requirements. If in doubt (or if the
|
||||||
|
checkout fails/the information is not properly saved), try setting the question
|
||||||
|
type to a simple type like "Text (one line)".
|
||||||
|
|
||||||
|
Notes and configuration examples
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
Requesting SAML 1.0 and 2.0 attributes from an academic IdP
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
This requests the ``eduPersonPrincipalName`` (also sometimes called EPPN),
|
||||||
|
``email``, ``givenName`` and ``sn`` both in SAML 1.0 and SAML 2.0 attributes.
|
||||||
|
|
||||||
|
.. sourcecode:: json
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"attributeValue": [],
|
||||||
|
"friendlyName": "eduPersonPrincipalName",
|
||||||
|
"isRequired": true,
|
||||||
|
"name": "urn:mace:dir:attribute-def:eduPersonPrincipalName",
|
||||||
|
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attributeValue": [],
|
||||||
|
"friendlyName": "eduPersonPrincipalName",
|
||||||
|
"isRequired": true,
|
||||||
|
"name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
|
||||||
|
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attributeValue": [],
|
||||||
|
"friendlyName": "email",
|
||||||
|
"isRequired": true,
|
||||||
|
"name": "urn:mace:dir:attribute-def:mail",
|
||||||
|
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attributeValue": [],
|
||||||
|
"friendlyName": "email",
|
||||||
|
"isRequired": true,
|
||||||
|
"name": "urn:oid:0.9.2342.19200300.100.1.3",
|
||||||
|
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attributeValue": [],
|
||||||
|
"friendlyName": "givenName",
|
||||||
|
"isRequired": true,
|
||||||
|
"name": "urn:mace:dir:attribute-def:givenName",
|
||||||
|
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attributeValue": [],
|
||||||
|
"friendlyName": "givenName",
|
||||||
|
"isRequired": true,
|
||||||
|
"name": "urn:oid:2.5.4.42",
|
||||||
|
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attributeValue": [],
|
||||||
|
"friendlyName": "sn",
|
||||||
|
"isRequired": true,
|
||||||
|
"name": "urn:mace:dir:attribute-def:sn",
|
||||||
|
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attributeValue": [],
|
||||||
|
"friendlyName": "sn",
|
||||||
|
"isRequired": true,
|
||||||
|
"name": "urn:oid:2.5.4.4",
|
||||||
|
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
skIDentity IdP Metadata URL
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Since the IdP Metadata URL for `skIDentity`_ is not readily documented/visible
|
||||||
|
in their backend, we document it here:
|
||||||
|
``https://service.skidentity.de/fs/saml/metadata``
|
||||||
|
|
||||||
|
Requesting skIDentity attributes for electronic identity cards
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
This requests the basic ``eIdentifier``, ``IDType``, ``IDIssuer``, and
|
||||||
|
``NameID`` from the `skIDentity`_ SAML service, which are available for
|
||||||
|
electronic ID cards such as the German ePA/NPA. (Other attributes such as
|
||||||
|
the name and address are available at additional cost from the IdP).
|
||||||
|
|
||||||
|
.. sourcecode:: json
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"attributeValue": [],
|
||||||
|
"friendlyName": "eIdentifier",
|
||||||
|
"isRequired": true,
|
||||||
|
"name": "http://www.skidentity.de/att/eIdentifier",
|
||||||
|
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attributeValue": [],
|
||||||
|
"friendlyName": "IDType",
|
||||||
|
"isRequired": true,
|
||||||
|
"name": "http://www.skidentity.de/att/IDType",
|
||||||
|
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attributeValue": [],
|
||||||
|
"friendlyName": "IDIssuer",
|
||||||
|
"isRequired": true,
|
||||||
|
"name": "http://www.skidentity.de/att/IDIssuer",
|
||||||
|
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attributeValue": [],
|
||||||
|
"friendlyName": "NameID",
|
||||||
|
"isRequired": true,
|
||||||
|
"name": "http://www.skidentity.de/att/NameID",
|
||||||
|
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
.. _pretix.eu: https://pretix.eu
|
||||||
|
.. _Test-, Basic- and Advanced-Federation: https://doku.tid.dfn.de/en:metadata
|
||||||
|
.. _skIDentity: https://www.skidentity.de/
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
pretixdroid HTTP API
|
|
||||||
====================
|
|
||||||
|
|
||||||
The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_
|
|
||||||
uses to communicate with the pretix server.
|
|
||||||
|
|
||||||
.. warning:: This API is **DEPRECATED** and will probably go away soon. It is used **only** to serve the pretixdroid
|
|
||||||
Android app. There are no backwards compatibility guarantees on this API. We will not add features that
|
|
||||||
are not required for the Android App. There is a general-purpose :ref:`rest-api` that provides all
|
|
||||||
features that you need to check in.
|
|
||||||
|
|
||||||
.. versionchanged:: 1.12
|
|
||||||
|
|
||||||
Support for check-in-time questions has been added. The new API features are fully backwards-compatible and
|
|
||||||
negotiated live, so clients which do not need this feature can ignore the change. For this reason, the API version
|
|
||||||
has not been increased and is still set to 3.
|
|
||||||
|
|
||||||
.. versionchanged:: 1.13
|
|
||||||
|
|
||||||
Support for checking in unpaid tickets has been added.
|
|
||||||
|
|
||||||
|
|
||||||
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
|
|
||||||
|
|
||||||
Redeems a ticket, i.e. checks the user in.
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /pretixdroid/api/demoorga/democon/redeem/?key=ABCDEF HTTP/1.1
|
|
||||||
Host: demo.pretix.eu
|
|
||||||
Accept: application/json, text/javascript
|
|
||||||
Content-Type: application/x-www-form-urlencoded
|
|
||||||
|
|
||||||
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3&questions_supported=true
|
|
||||||
|
|
||||||
You **must** set the parameter secret.
|
|
||||||
|
|
||||||
You **must** set the parameter ``questions_supported`` to ``true`` **if** you support asking questions
|
|
||||||
back to the app operator. You **must not** set it if you do not support this feature. In that case, questions
|
|
||||||
will just be ignored.
|
|
||||||
|
|
||||||
You **may** set the additional parameter ``datetime`` in the body containing an ISO8601-encoded
|
|
||||||
datetime of the entry attempt. If you don"t, the current date and time will be used.
|
|
||||||
|
|
||||||
You **may** set the additional parameter ``force`` to indicate that the request should be logged
|
|
||||||
regardless of previous check-ins for the same ticket. This might be useful if you made the entry decision offline.
|
|
||||||
Questions will also always be ignored in this case (i.e. supplied answers will be saved, but no error will be
|
|
||||||
thrown if they are missing or invalid).
|
|
||||||
|
|
||||||
You **may** set the additional parameter ``nonce`` with a globally unique random value to identify this
|
|
||||||
check-in. This is meant to be used to prevent duplicate check-ins when you are just retrying after a connection
|
|
||||||
failure.
|
|
||||||
|
|
||||||
You **may** set the additional parameter ``ignore_unpaid`` to indicate that the check-in should be performed even
|
|
||||||
if the order is in pending state.
|
|
||||||
|
|
||||||
If questions are supported and required, you will receive a dictionary ``questions`` containing details on the
|
|
||||||
particular questions to ask. To answer them, just re-send your redemption request with additional parameters of
|
|
||||||
the form ``answer_<question>=<answer>``, e.g. ``answer_12=24``.
|
|
||||||
|
|
||||||
**Example successful response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Content-Type: text/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"status": "ok"
|
|
||||||
"version": 3,
|
|
||||||
"data": {
|
|
||||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
|
||||||
"order": "ABCDE",
|
|
||||||
"item": "Standard ticket",
|
|
||||||
"item_id": 1,
|
|
||||||
"variation": null,
|
|
||||||
"variation_id": null,
|
|
||||||
"attendee_name": "Peter Higgs",
|
|
||||||
"attention": false,
|
|
||||||
"redeemed": true,
|
|
||||||
"checkin_allowed": true,
|
|
||||||
"addons_text": "Parking spot",
|
|
||||||
"paid": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
**Example response with required questions**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Content-Type: text/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"status": "incomplete"
|
|
||||||
"version": 3
|
|
||||||
"data": {
|
|
||||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
|
||||||
"order": "ABCDE",
|
|
||||||
"item": "Standard ticket",
|
|
||||||
"item_id": 1,
|
|
||||||
"variation": null,
|
|
||||||
"variation_id": null,
|
|
||||||
"attendee_name": "Peter Higgs",
|
|
||||||
"attention": false,
|
|
||||||
"redeemed": true,
|
|
||||||
"checkin_allowed": true,
|
|
||||||
"addons_text": "Parking spot",
|
|
||||||
"paid": true
|
|
||||||
},
|
|
||||||
"questions": [
|
|
||||||
{
|
|
||||||
"id": 12,
|
|
||||||
"type": "C",
|
|
||||||
"question": "Choose a shirt size",
|
|
||||||
"required": true,
|
|
||||||
"position": 2,
|
|
||||||
"items": [1],
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"id": 24,
|
|
||||||
"answer": "M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 25,
|
|
||||||
"answer": "L"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
**Example error response with data**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Content-Type: text/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"status": "error",
|
|
||||||
"reason": "already_redeemed",
|
|
||||||
"version": 3,
|
|
||||||
"data": {
|
|
||||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
|
||||||
"order": "ABCDE",
|
|
||||||
"item": "Standard ticket",
|
|
||||||
"item_id": 1,
|
|
||||||
"variation": null,
|
|
||||||
"variation_id": null,
|
|
||||||
"attendee_name": "Peter Higgs",
|
|
||||||
"attention": false,
|
|
||||||
"redeemed": true,
|
|
||||||
"checkin_allowed": true,
|
|
||||||
"addons_text": "Parking spot",
|
|
||||||
"paid": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
**Example error response without data**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Content-Type: text/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"status": "error",
|
|
||||||
"reason": "unkown_ticket",
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
|
|
||||||
Possible error reasons:
|
|
||||||
|
|
||||||
* ``unpaid`` - Ticket is not paid for or has been refunded
|
|
||||||
* ``already_redeemed`` - Ticket already has been redeemed
|
|
||||||
* ``product`` - Tickets with this product may not be scanned at this device
|
|
||||||
* ``unknown_ticket`` - Secret does not match a ticket in the database
|
|
||||||
|
|
||||||
:query key: Secret API key
|
|
||||||
:statuscode 200: Valid request
|
|
||||||
:statuscode 404: Unknown organizer or event
|
|
||||||
:statuscode 403: Invalid authorization key
|
|
||||||
|
|
||||||
.. http:get:: /pretixdroid/api/(organizer)/(event)/search/
|
|
||||||
|
|
||||||
Searches for a ticket.
|
|
||||||
At most 25 results will be returned. **Queries with less than 4 characters will always return an empty result set.**
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /pretixdroid/api/demoorga/democon/search/?key=ABCDEF&query=Peter HTTP/1.1
|
|
||||||
Host: demo.pretix.eu
|
|
||||||
Accept: application/json, text/javascript
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Content-Type: text/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
|
||||||
"order": "ABCE6",
|
|
||||||
"item": "Standard ticket",
|
|
||||||
"variation": null,
|
|
||||||
"attendee_name": "Peter Higgs",
|
|
||||||
"redeemed": false,
|
|
||||||
"attention": false,
|
|
||||||
"checkin_allowed": true,
|
|
||||||
"addons_text": "Parking spot",
|
|
||||||
"paid": true
|
|
||||||
},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
"version": 3
|
|
||||||
}
|
|
||||||
|
|
||||||
:query query: Search query
|
|
||||||
:query key: Secret API key
|
|
||||||
:statuscode 200: Valid request
|
|
||||||
:statuscode 404: Unknown organizer or event
|
|
||||||
:statuscode 403: Invalid authorization key
|
|
||||||
|
|
||||||
.. http:get:: /pretixdroid/api/(organizer)/(event)/download/
|
|
||||||
|
|
||||||
Download data for all tickets.
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /pretixdroid/api/demoorga/democon/download/?key=ABCDEF HTTP/1.1
|
|
||||||
Host: demo.pretix.eu
|
|
||||||
Accept: application/json, text/javascript
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Content-Type: text/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"version": 3,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
|
||||||
"order": "ABCE6",
|
|
||||||
"item": "Standard ticket",
|
|
||||||
"variation": null,
|
|
||||||
"attendee_name": "Peter Higgs",
|
|
||||||
"redeemed": false,
|
|
||||||
"attention": false,
|
|
||||||
"checkin_allowed": true,
|
|
||||||
"paid": true
|
|
||||||
},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
"questions": [
|
|
||||||
{
|
|
||||||
"id": 12,
|
|
||||||
"type": "C",
|
|
||||||
"question": "Choose a shirt size",
|
|
||||||
"required": true,
|
|
||||||
"position": 2,
|
|
||||||
"items": [1],
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"id": 24,
|
|
||||||
"answer": "M"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 25,
|
|
||||||
"answer": "L"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
:query key: Secret API key
|
|
||||||
:statuscode 200: Valid request
|
|
||||||
:statuscode 404: Unknown organizer or event
|
|
||||||
:statuscode 403: Invalid authorization key
|
|
||||||
|
|
||||||
.. http:get:: /pretixdroid/api/(organizer)/(event)/status/
|
|
||||||
|
|
||||||
Returns status information, such as the total number of tickets and the
|
|
||||||
number of performed check-ins.
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
GET /pretixdroid/api/demoorga/democon/status/?key=ABCDEF HTTP/1.1
|
|
||||||
Host: demo.pretix.eu
|
|
||||||
Accept: application/json, text/javascript
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Content-Type: text/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"checkins": 17,
|
|
||||||
"total": 42,
|
|
||||||
"version": 3,
|
|
||||||
"event": {
|
|
||||||
"name": "Demo Conference",
|
|
||||||
"slug": "democon",
|
|
||||||
"date_from": "2016-12-27T17:00:00Z",
|
|
||||||
"date_to": "2016-12-30T18:00:00Z",
|
|
||||||
"timezone": "UTC",
|
|
||||||
"url": "https://demo.pretix.eu/demoorga/democon/",
|
|
||||||
"organizer": {
|
|
||||||
"name": "Demo Organizer",
|
|
||||||
"slug": "demoorga"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"name": "T-Shirt",
|
|
||||||
"id": 1,
|
|
||||||
"checkins": 1,
|
|
||||||
"admission": False,
|
|
||||||
"total": 1,
|
|
||||||
"variations": [
|
|
||||||
{
|
|
||||||
"name": "Red",
|
|
||||||
"id": 1,
|
|
||||||
"checkins": 1,
|
|
||||||
"total": 12
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Blue",
|
|
||||||
"id": 2,
|
|
||||||
"checkins": 4,
|
|
||||||
"total": 8
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Ticket",
|
|
||||||
"id": 2,
|
|
||||||
"checkins": 15,
|
|
||||||
"admission": True,
|
|
||||||
"total": 22,
|
|
||||||
"variations": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
:query key: Secret API key
|
|
||||||
:statuscode 200: Valid request
|
|
||||||
:statuscode 404: Unknown organizer or event
|
|
||||||
:statuscode 403: Invalid authorization key
|
|
||||||
|
|
||||||
.. _pretixdroid Android app: https://github.com/pretix/pretixdroid
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
-e ../src/
|
-e ../src/
|
||||||
sphinx==2.3.*
|
sphinx==2.3.*
|
||||||
|
jinja2==3.0.*
|
||||||
sphinx-rtd-theme
|
sphinx-rtd-theme
|
||||||
sphinxcontrib-httpdomain
|
sphinxcontrib-httpdomain
|
||||||
sphinxcontrib-images
|
sphinxcontrib-images
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ prepending
|
|||||||
preprocessor
|
preprocessor
|
||||||
presale
|
presale
|
||||||
pretix
|
pretix
|
||||||
|
pretixLEAD
|
||||||
pretixSCAN
|
pretixSCAN
|
||||||
pretixdroid
|
pretixdroid
|
||||||
pretixPOS
|
pretixPOS
|
||||||
|
|||||||
@@ -203,4 +203,4 @@ Then, please contact support@pretix.eu and we will enable DKIM for your domain o
|
|||||||
|
|
||||||
|
|
||||||
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
|
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
|
||||||
.. _SPF specification: http://www.openspf.org/SPF_Record_Syntax
|
.. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
.. _secret_generators:
|
||||||
|
|
||||||
Ticket secret generators
|
Ticket secret generators
|
||||||
========================
|
========================
|
||||||
|
|
||||||
|
|||||||
@@ -253,18 +253,21 @@ If you want, you can suppress us loading the widget and/or modify the user data
|
|||||||
|
|
||||||
If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``.
|
If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``.
|
||||||
|
|
||||||
Waiting for the widget to load
|
Waiting for the widget to load or close
|
||||||
------------------------------
|
---------------------------------------
|
||||||
|
|
||||||
If you want to run custom JavaScript once the widget is fully loaded, you can register a callback function. Note that
|
If you want to run custom JavaScript once the widget is fully loaded or when it is closed, you can register callback
|
||||||
this function might be run multiple times, for example if you have multiple widgets on a page or if the user switches
|
functions. Note that these function might be run multiple times, for example if you have multiple widgets on a page
|
||||||
e.g. from an event list to an event detail view::
|
or if the user switches e.g. from an event list to an event detail view::
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
window.pretixWidgetCallback = function () {
|
window.pretixWidgetCallback = function () {
|
||||||
window.PretixWidget.addLoadListener(function () {
|
window.PretixWidget.addLoadListener(function () {
|
||||||
console.log("Widget has loaded!");
|
console.log("Widget has loaded!");
|
||||||
});
|
});
|
||||||
|
window.PretixWidget.addCloseListener(function () {
|
||||||
|
console.log("Widget has been closed!");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -309,6 +312,10 @@ Currently, the following attributes are understood by pretix itself:
|
|||||||
always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely
|
always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely
|
||||||
on this for authentication.
|
on this for authentication.
|
||||||
|
|
||||||
|
* If ``data-consent="…"`` is given, the cookie consent mechanism will be initialized with consent for the given cookie
|
||||||
|
providers. All other providers will be disabled, no consent dialog will be shown. This is useful if you already
|
||||||
|
asked the user for consent and don't want them to be asked again. Example: ``data-consent="facebook,google_analytics"``
|
||||||
|
|
||||||
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
|
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
|
||||||
Hosted or pretix Enterprise are active, you can pass the following fields:
|
Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ recursive-include pretix/plugins/banktransfer/static *
|
|||||||
recursive-include pretix/plugins/manualpayment/templates *
|
recursive-include pretix/plugins/manualpayment/templates *
|
||||||
recursive-include pretix/plugins/manualpayment/static *
|
recursive-include pretix/plugins/manualpayment/static *
|
||||||
recursive-include pretix/plugins/paypal/templates *
|
recursive-include pretix/plugins/paypal/templates *
|
||||||
|
recursive-include pretix/plugins/paypal/static *
|
||||||
recursive-include pretix/plugins/pretixdroid/templates *
|
recursive-include pretix/plugins/pretixdroid/templates *
|
||||||
recursive-include pretix/plugins/pretixdroid/static *
|
recursive-include pretix/plugins/pretixdroid/static *
|
||||||
recursive-include pretix/plugins/sendmail/templates *
|
recursive-include pretix/plugins/sendmail/templates *
|
||||||
|
|||||||
@@ -19,4 +19,4 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
__version__ = "4.5.0.dev0"
|
__version__ = "4.9.1"
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
|||||||
('GET', 'api-v1:checkinlist-list'),
|
('GET', 'api-v1:checkinlist-list'),
|
||||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||||
('POST', 'plugins:pretix_posbackend:order.posprintlog'),
|
('POST', 'plugins:pretix_posbackend:order.posprintlog'),
|
||||||
|
('POST', 'plugins:pretix_posbackend:order.poslock'),
|
||||||
|
('DELETE', 'plugins:pretix_posbackend:order.poslock'),
|
||||||
('DELETE', 'api-v1:cartposition-detail'),
|
('DELETE', 'api-v1:cartposition-detail'),
|
||||||
('GET', 'api-v1:giftcard-list'),
|
('GET', 'api-v1:giftcard-list'),
|
||||||
('POST', 'api-v1:giftcard-transact'),
|
('POST', 'api-v1:giftcard-transact'),
|
||||||
@@ -174,8 +176,11 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
|||||||
('POST', 'plugins:pretix_posbackend:posreceipt-list'),
|
('POST', 'plugins:pretix_posbackend:posreceipt-list'),
|
||||||
('POST', 'plugins:pretix_posbackend:posclosing-list'),
|
('POST', 'plugins:pretix_posbackend:posclosing-list'),
|
||||||
('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
|
('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
|
||||||
|
('POST', 'plugins:pretix_posbackend:posdebuglogentry-list'),
|
||||||
|
('POST', 'plugins:pretix_posbackend:posdebuglogentry-bulk-create'),
|
||||||
('GET', 'plugins:pretix_posbackend:poscashier-list'),
|
('GET', 'plugins:pretix_posbackend:poscashier-list'),
|
||||||
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
|
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
|
||||||
|
('PUT', 'plugins:pretix_posbackend:file.upload'),
|
||||||
('GET', 'api-v1:revokedsecrets-list'),
|
('GET', 'api-v1:revokedsecrets-list'),
|
||||||
('GET', 'api-v1:event.settings'),
|
('GET', 'api-v1:event.settings'),
|
||||||
('GET', 'plugins:pretix_seating:event.event'),
|
('GET', 'plugins:pretix_seating:event.event'),
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
|
|||||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||||
full_data.update(data)
|
full_data.update(data)
|
||||||
|
|
||||||
for item in full_data.get('limit_products'):
|
for item in full_data.get('limit_products', []):
|
||||||
if event != item.event:
|
if event != item.event:
|
||||||
raise ValidationError(_('One or more items do not belong to this event.'))
|
raise ValidationError(_('One or more items do not belong to this event.'))
|
||||||
|
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
|||||||
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TaxRule
|
model = TaxRule
|
||||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
|
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes')
|
||||||
|
|
||||||
|
|
||||||
class EventSettingsSerializer(SettingsSerializer):
|
class EventSettingsSerializer(SettingsSerializer):
|
||||||
@@ -713,7 +713,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'ticket_download_require_validated_email',
|
'ticket_download_require_validated_email',
|
||||||
'ticket_secret_length',
|
'ticket_secret_length',
|
||||||
'mail_prefix',
|
'mail_prefix',
|
||||||
'mail_from',
|
|
||||||
'mail_from_name',
|
'mail_from_name',
|
||||||
'mail_attach_ical',
|
'mail_attach_ical',
|
||||||
'mail_attach_tickets',
|
'mail_attach_tickets',
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ItemVariation
|
model = ItemVariation
|
||||||
fields = ('id', 'value', 'active', 'description',
|
fields = ('id', 'value', 'active', 'description',
|
||||||
'position', 'default_price', 'price', 'original_price',
|
'position', 'default_price', 'price', 'original_price', 'require_approval',
|
||||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
|
'require_membership', 'require_membership_types',
|
||||||
|
'require_membership_hidden', 'available_from', 'available_until',
|
||||||
'sales_channels', 'hide_without_voucher',)
|
'sales_channels', 'hide_without_voucher',)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -74,8 +75,9 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ItemVariation
|
model = ItemVariation
|
||||||
fields = ('id', 'value', 'active', 'description',
|
fields = ('id', 'value', 'active', 'description',
|
||||||
'position', 'default_price', 'price', 'original_price',
|
'position', 'default_price', 'price', 'original_price', 'require_approval',
|
||||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
|
'require_membership', 'require_membership_types',
|
||||||
|
'require_membership_hidden', 'available_from', 'available_until',
|
||||||
'sales_channels', 'hide_without_voucher',)
|
'sales_channels', 'hide_without_voucher',)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -249,9 +251,12 @@ class ItemSerializer(I18nAwareModelSerializer):
|
|||||||
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
|
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
|
||||||
meta_data = validated_data.pop('meta_data', None)
|
meta_data = validated_data.pop('meta_data', None)
|
||||||
picture = validated_data.pop('picture', None)
|
picture = validated_data.pop('picture', None)
|
||||||
|
require_membership_types = validated_data.pop('require_membership_types', [])
|
||||||
item = Item.objects.create(**validated_data)
|
item = Item.objects.create(**validated_data)
|
||||||
if picture:
|
if picture:
|
||||||
item.picture.save(os.path.basename(picture.name), picture)
|
item.picture.save(os.path.basename(picture.name), picture)
|
||||||
|
if require_membership_types:
|
||||||
|
item.require_membership_types.add(*require_membership_types)
|
||||||
|
|
||||||
for variation_data in variations_data:
|
for variation_data in variations_data:
|
||||||
require_membership_types = variation_data.pop('require_membership_types', [])
|
require_membership_types = variation_data.pop('require_membership_types', [])
|
||||||
|
|||||||
@@ -424,88 +424,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
|||||||
self.fields.pop('pdf_data', None)
|
self.fields.pop('pdf_data', None)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
if data.get('attendee_name') and data.get('attendee_name_parts'):
|
raise TypeError("this serializer is readonly")
|
||||||
raise ValidationError(
|
|
||||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
|
||||||
)
|
|
||||||
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
|
|
||||||
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
|
||||||
|
|
||||||
if data.get('country'):
|
|
||||||
if not pycountry.countries.get(alpha_2=data.get('country').code):
|
|
||||||
raise ValidationError(
|
|
||||||
{'country': ['Invalid country code.']}
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.get('state'):
|
|
||||||
cc = str(data.get('country') or self.instance.country or '')
|
|
||||||
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
|
||||||
raise ValidationError(
|
|
||||||
{'state': ['States are not supported in country "{}".'.format(cc)]}
|
|
||||||
)
|
|
||||||
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
|
|
||||||
raise ValidationError(
|
|
||||||
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
|
||||||
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
|
|
||||||
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
|
|
||||||
update_fields = [
|
|
||||||
'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
|
|
||||||
'state', 'attendee_email',
|
|
||||||
]
|
|
||||||
answers_data = validated_data.pop('answers', None)
|
|
||||||
|
|
||||||
name = validated_data.pop('attendee_name', '')
|
|
||||||
if name and not validated_data.get('attendee_name_parts'):
|
|
||||||
validated_data['attendee_name_parts'] = {
|
|
||||||
'_legacy': name
|
|
||||||
}
|
|
||||||
|
|
||||||
for attr, value in validated_data.items():
|
|
||||||
if attr in update_fields:
|
|
||||||
setattr(instance, attr, value)
|
|
||||||
|
|
||||||
instance.save(update_fields=update_fields)
|
|
||||||
|
|
||||||
if answers_data is not None:
|
|
||||||
qs_seen = set()
|
|
||||||
answercache = {
|
|
||||||
a.question_id: a for a in instance.answers.all()
|
|
||||||
}
|
|
||||||
for answ_data in answers_data:
|
|
||||||
options = answ_data.pop('options', [])
|
|
||||||
if answ_data['question'].pk in qs_seen:
|
|
||||||
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
|
|
||||||
if answ_data['question'].pk in answercache:
|
|
||||||
a = answercache[answ_data['question'].pk]
|
|
||||||
if isinstance(answ_data['answer'], File):
|
|
||||||
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
|
|
||||||
a.answer = 'file://' + a.file.name
|
|
||||||
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
|
|
||||||
pass # keep current file
|
|
||||||
else:
|
|
||||||
for attr, value in answ_data.items():
|
|
||||||
setattr(a, attr, value)
|
|
||||||
a.save()
|
|
||||||
else:
|
|
||||||
if isinstance(answ_data['answer'], File):
|
|
||||||
an = answ_data.pop('answer')
|
|
||||||
a = instance.answers.create(**answ_data, answer='')
|
|
||||||
a.file.save(os.path.basename(an.name), an, save=False)
|
|
||||||
a.answer = 'file://' + a.file.name
|
|
||||||
a.save()
|
|
||||||
else:
|
|
||||||
a = instance.answers.create(**answ_data)
|
|
||||||
a.options.set(options)
|
|
||||||
qs_seen.add(a.question_id)
|
|
||||||
for qid, a in answercache.items():
|
|
||||||
if qid not in qs_seen:
|
|
||||||
a.delete()
|
|
||||||
|
|
||||||
return instance
|
|
||||||
|
|
||||||
|
|
||||||
class RequireAttentionField(serializers.Field):
|
class RequireAttentionField(serializers.Field):
|
||||||
@@ -593,7 +512,7 @@ class OrderPaymentDateField(serializers.DateField):
|
|||||||
class OrderFeeSerializer(I18nAwareModelSerializer):
|
class OrderFeeSerializer(I18nAwareModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = OrderFee
|
model = OrderFee
|
||||||
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
|
fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
|
||||||
|
|
||||||
|
|
||||||
class PaymentURLField(serializers.URLField):
|
class PaymentURLField(serializers.URLField):
|
||||||
@@ -934,7 +853,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
|
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
|
||||||
force = serializers.BooleanField(default=False, required=False)
|
force = serializers.BooleanField(default=False, required=False)
|
||||||
payment_date = serializers.DateTimeField(required=False, allow_null=True)
|
payment_date = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
send_email = serializers.BooleanField(default=False, required=False)
|
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
|
||||||
|
require_approval = serializers.BooleanField(default=False, required=False)
|
||||||
simulate = serializers.BooleanField(default=False, required=False)
|
simulate = serializers.BooleanField(default=False, required=False)
|
||||||
customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none(), required=False)
|
customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none(), required=False)
|
||||||
|
|
||||||
@@ -947,7 +867,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
model = Order
|
model = Order
|
||||||
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||||
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
|
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
|
||||||
'force', 'send_email', 'simulate', 'customer', 'custom_followup_at')
|
'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', 'require_approval')
|
||||||
|
|
||||||
def validate_payment_provider(self, pp):
|
def validate_payment_provider(self, pp):
|
||||||
if pp is None:
|
if pp is None:
|
||||||
@@ -1041,6 +961,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
force = validated_data.pop('force', False)
|
force = validated_data.pop('force', False)
|
||||||
simulate = validated_data.pop('simulate', False)
|
simulate = validated_data.pop('simulate', False)
|
||||||
self._send_mail = validated_data.pop('send_email', False)
|
self._send_mail = validated_data.pop('send_email', False)
|
||||||
|
if self._send_mail is None:
|
||||||
|
self._send_mail = validated_data.get('sales_channel') in self.context['event'].settings.mail_sales_channel_placed_paid
|
||||||
|
|
||||||
if 'invoice_address' in validated_data:
|
if 'invoice_address' in validated_data:
|
||||||
iadata = validated_data.pop('invoice_address')
|
iadata = validated_data.pop('invoice_address')
|
||||||
@@ -1219,6 +1141,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
|
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
|
||||||
order.meta_info = "{}"
|
order.meta_info = "{}"
|
||||||
order.total = Decimal('0.00')
|
order.total = Decimal('0.00')
|
||||||
|
if validated_data.get('require_approval') is not None:
|
||||||
|
order.require_approval = validated_data['require_approval']
|
||||||
if simulate:
|
if simulate:
|
||||||
order = WrappedModel(order)
|
order = WrappedModel(order)
|
||||||
order.last_modified = now()
|
order.last_modified = now()
|
||||||
@@ -1356,14 +1280,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
f.order = order._wrapped if simulate else order
|
f.order = order._wrapped if simulate else order
|
||||||
f._calculate_tax()
|
f._calculate_tax()
|
||||||
fees.append(f)
|
fees.append(f)
|
||||||
if not simulate:
|
if simulate:
|
||||||
|
f.id = 0
|
||||||
|
else:
|
||||||
f.save()
|
f.save()
|
||||||
else:
|
else:
|
||||||
f = OrderFee(**fee_data)
|
f = OrderFee(**fee_data)
|
||||||
f.order = order._wrapped if simulate else order
|
f.order = order._wrapped if simulate else order
|
||||||
f._calculate_tax()
|
f._calculate_tax()
|
||||||
fees.append(f)
|
fees.append(f)
|
||||||
if not simulate:
|
if simulate:
|
||||||
|
f.id = 0
|
||||||
|
else:
|
||||||
f.save()
|
f.save()
|
||||||
|
|
||||||
order.total += sum([f.value for f in fees])
|
order.total += sum([f.value for f in fees])
|
||||||
@@ -1426,7 +1354,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InvoiceLine
|
model = InvoiceLine
|
||||||
fields = ('position', 'description', 'item', 'variation', 'attendee_name', 'event_date_from',
|
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
|
||||||
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
|
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
|
||||||
'fee_internal_type', 'event_location')
|
'fee_internal_type', 'event_location')
|
||||||
|
|
||||||
|
|||||||
424
src/pretix/api/serializers/orderchange.py
Normal file
424
src/pretix/api/serializers/orderchange.py
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pycountry
|
||||||
|
from django.core.files import File
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
|
from pretix.api.serializers.order import (
|
||||||
|
AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField,
|
||||||
|
OrderPositionCreateSerializer,
|
||||||
|
)
|
||||||
|
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
|
||||||
|
from pretix.base.services.orders import OrderError
|
||||||
|
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerializer):
|
||||||
|
order = serializers.SlugRelatedField(slug_field='code', queryset=Order.objects.none(), required=True, allow_null=False)
|
||||||
|
answers = AnswerCreateSerializer(many=True, required=False)
|
||||||
|
addon_to = serializers.IntegerField(required=False, allow_null=True)
|
||||||
|
secret = serializers.CharField(required=False)
|
||||||
|
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||||
|
seat = serializers.CharField(required=False, allow_null=True)
|
||||||
|
price = serializers.DecimalField(required=False, allow_null=True, decimal_places=2,
|
||||||
|
max_digits=10)
|
||||||
|
country = CompatibleCountryField(source='*')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OrderPosition
|
||||||
|
fields = ('order', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||||
|
'company', 'street', 'zipcode', 'city', 'country', 'state',
|
||||||
|
'secret', 'addon_to', 'subevent', 'answers', 'seat')
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if not self.context:
|
||||||
|
return
|
||||||
|
self.fields['order'].queryset = self.context['event'].orders.all()
|
||||||
|
self.fields['item'].queryset = self.context['event'].items.all()
|
||||||
|
self.fields['subevent'].queryset = self.context['event'].subevents.all()
|
||||||
|
self.fields['seat'].queryset = self.context['event'].seats.all()
|
||||||
|
self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=self.context['event'])
|
||||||
|
if 'order' in self.context:
|
||||||
|
del self.fields['order']
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
data = super().validate(data)
|
||||||
|
if data.get('addon_to'):
|
||||||
|
try:
|
||||||
|
data['addon_to'] = data['order'].positions.get(positionid=data['addon_to'])
|
||||||
|
except OrderPosition.DoesNotExist:
|
||||||
|
raise ValidationError({
|
||||||
|
'addon_to': ['addon_to refers to an unknown position ID for this order.']
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
ocm = self.context['ocm']
|
||||||
|
|
||||||
|
try:
|
||||||
|
ocm.add_position(
|
||||||
|
item=validated_data['item'],
|
||||||
|
variation=validated_data.get('variation'),
|
||||||
|
price=validated_data.get('price'),
|
||||||
|
addon_to=validated_data.get('addon_to'),
|
||||||
|
subevent=validated_data.get('subevent'),
|
||||||
|
seat=validated_data.get('seat'),
|
||||||
|
)
|
||||||
|
if self.context.get('commit', True):
|
||||||
|
ocm.commit()
|
||||||
|
return validated_data['order'].positions.order_by('-positionid').first()
|
||||||
|
else:
|
||||||
|
return OrderPosition() # fake to appease DRF
|
||||||
|
except OrderError as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
|
||||||
|
answers = AnswerSerializer(many=True)
|
||||||
|
country = CompatibleCountryField(source='*')
|
||||||
|
attendee_name = serializers.CharField(required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OrderPosition
|
||||||
|
fields = (
|
||||||
|
'attendee_name', 'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
|
||||||
|
'state', 'attendee_email', 'answers',
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
if data.get('attendee_name') and data.get('attendee_name_parts'):
|
||||||
|
raise ValidationError(
|
||||||
|
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||||
|
)
|
||||||
|
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
|
||||||
|
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||||
|
|
||||||
|
if data.get('country'):
|
||||||
|
if not pycountry.countries.get(alpha_2=data.get('country').code):
|
||||||
|
raise ValidationError(
|
||||||
|
{'country': ['Invalid country code.']}
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('state'):
|
||||||
|
cc = str(data.get('country') or self.instance.country or '')
|
||||||
|
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||||
|
raise ValidationError(
|
||||||
|
{'state': ['States are not supported in country "{}".'.format(cc)]}
|
||||||
|
)
|
||||||
|
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
|
||||||
|
raise ValidationError(
|
||||||
|
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
answers_data = validated_data.pop('answers', None)
|
||||||
|
|
||||||
|
name = validated_data.pop('attendee_name', '')
|
||||||
|
if name and not validated_data.get('attendee_name_parts'):
|
||||||
|
validated_data['attendee_name_parts'] = {
|
||||||
|
'_legacy': name
|
||||||
|
}
|
||||||
|
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
if attr in self.fields:
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
|
||||||
|
instance.save(update_fields=list(validated_data.keys()))
|
||||||
|
|
||||||
|
if answers_data is not None:
|
||||||
|
qs_seen = set()
|
||||||
|
answercache = {
|
||||||
|
a.question_id: a for a in instance.answers.all()
|
||||||
|
}
|
||||||
|
for answ_data in answers_data:
|
||||||
|
options = answ_data.pop('options', [])
|
||||||
|
if answ_data['question'].pk in qs_seen:
|
||||||
|
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
|
||||||
|
if answ_data['question'].pk in answercache:
|
||||||
|
a = answercache[answ_data['question'].pk]
|
||||||
|
if isinstance(answ_data['answer'], File):
|
||||||
|
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
|
||||||
|
a.answer = 'file://' + a.file.name
|
||||||
|
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
|
||||||
|
pass # keep current file
|
||||||
|
else:
|
||||||
|
for attr, value in answ_data.items():
|
||||||
|
setattr(a, attr, value)
|
||||||
|
a.save()
|
||||||
|
else:
|
||||||
|
if isinstance(answ_data['answer'], File):
|
||||||
|
an = answ_data.pop('answer')
|
||||||
|
a = instance.answers.create(**answ_data, answer='')
|
||||||
|
a.file.save(os.path.basename(an.name), an, save=False)
|
||||||
|
a.answer = 'file://' + a.file.name
|
||||||
|
a.save()
|
||||||
|
else:
|
||||||
|
a = instance.answers.create(**answ_data)
|
||||||
|
a.options.set(options)
|
||||||
|
qs_seen.add(a.question_id)
|
||||||
|
for qid, a in answercache.items():
|
||||||
|
if qid not in qs_seen:
|
||||||
|
a.delete()
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class OrderPositionChangeSerializer(serializers.ModelSerializer):
|
||||||
|
seat = serializers.CharField(source='seat.seat_guid', allow_null=True, required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OrderPosition
|
||||||
|
fields = (
|
||||||
|
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule',
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if not self.context:
|
||||||
|
return
|
||||||
|
self.fields['item'].queryset = self.context['event'].items.all()
|
||||||
|
self.fields['subevent'].queryset = self.context['event'].subevents.all()
|
||||||
|
self.fields['tax_rule'].queryset = self.context['event'].tax_rules.all()
|
||||||
|
if kwargs.get('partial'):
|
||||||
|
for k, v in self.fields.items():
|
||||||
|
self.fields[k].required = False
|
||||||
|
|
||||||
|
def validate_item(self, item):
|
||||||
|
if item.event != self.context['event']:
|
||||||
|
raise ValidationError(
|
||||||
|
'The specified item does not belong to this event.'
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def validate_subevent(self, subevent):
|
||||||
|
if self.context['event'].has_subevents:
|
||||||
|
if not subevent:
|
||||||
|
raise ValidationError(
|
||||||
|
'You need to set a subevent.'
|
||||||
|
)
|
||||||
|
if subevent.event != self.context['event']:
|
||||||
|
raise ValidationError(
|
||||||
|
'The specified subevent does not belong to this event.'
|
||||||
|
)
|
||||||
|
elif subevent:
|
||||||
|
raise ValidationError(
|
||||||
|
'You cannot set a subevent for this event.'
|
||||||
|
)
|
||||||
|
return subevent
|
||||||
|
|
||||||
|
def validate(self, data, instance=None):
|
||||||
|
instance = instance or self.instance
|
||||||
|
if instance is None:
|
||||||
|
return data # needs to be done later
|
||||||
|
if data.get('item', instance.item):
|
||||||
|
if data.get('item', instance.item).has_variations:
|
||||||
|
if not data.get('variation', instance.variation):
|
||||||
|
raise ValidationError({'variation': ['You should specify a variation for this item.']})
|
||||||
|
else:
|
||||||
|
if data.get('variation', instance.variation).item != data.get('item', instance.item):
|
||||||
|
raise ValidationError(
|
||||||
|
{'variation': ['The specified variation does not belong to the specified item.']}
|
||||||
|
)
|
||||||
|
elif data.get('variation', instance.variation):
|
||||||
|
raise ValidationError(
|
||||||
|
{'variation': ['You cannot specify a variation for this item.']}
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
ocm = self.context['ocm']
|
||||||
|
current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None
|
||||||
|
item = validated_data.get('item', instance.item)
|
||||||
|
variation = validated_data.get('variation', instance.variation)
|
||||||
|
subevent = validated_data.get('subevent', instance.subevent)
|
||||||
|
price = validated_data.get('price', instance.price)
|
||||||
|
seat = validated_data.get('seat', current_seat)
|
||||||
|
tax_rule = validated_data.get('tax_rule', instance.tax_rule)
|
||||||
|
|
||||||
|
change_item = None
|
||||||
|
if item != instance.item or variation != instance.variation:
|
||||||
|
change_item = (item, variation)
|
||||||
|
|
||||||
|
change_subevent = None
|
||||||
|
if self.context['event'].has_subevents and subevent != instance.subevent:
|
||||||
|
change_subevent = (subevent,)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if change_item is not None and change_subevent is not None:
|
||||||
|
ocm.change_item_and_subevent(instance, *change_item, *change_subevent)
|
||||||
|
elif change_item is not None:
|
||||||
|
ocm.change_item(instance, *change_item)
|
||||||
|
elif change_subevent is not None:
|
||||||
|
ocm.change_subevent(instance, *change_subevent)
|
||||||
|
|
||||||
|
if seat != current_seat or change_subevent:
|
||||||
|
ocm.change_seat(instance, seat['seat_guid'] if seat else None)
|
||||||
|
|
||||||
|
if price != instance.price:
|
||||||
|
ocm.change_price(instance, price)
|
||||||
|
|
||||||
|
if tax_rule != instance.tax_rule:
|
||||||
|
ocm.change_tax_rule(instance, tax_rule)
|
||||||
|
|
||||||
|
if self.context.get('commit', True):
|
||||||
|
ocm.commit()
|
||||||
|
instance.refresh_from_db()
|
||||||
|
except OrderError as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class PatchPositionSerializer(serializers.Serializer):
|
||||||
|
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none())
|
||||||
|
|
||||||
|
def validate_position(self, value):
|
||||||
|
self.fields['body'].instance = value # hack around DRFs validation order
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
OrderPositionChangeSerializer(context=self.context, partial=True).validate(data['body'], data['position'])
|
||||||
|
return data
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['position'].queryset = self.context['order'].positions.all()
|
||||||
|
self.fields['body'] = OrderPositionChangeSerializer(context=self.context, partial=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SelectPositionSerializer(serializers.Serializer):
|
||||||
|
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none())
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['position'].queryset = self.context['order'].positions.all()
|
||||||
|
|
||||||
|
|
||||||
|
class OrderFeeChangeSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OrderFee
|
||||||
|
fields = (
|
||||||
|
'value',
|
||||||
|
)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
ocm = self.context['ocm']
|
||||||
|
value = validated_data.get('value', instance.value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if value != instance.value:
|
||||||
|
ocm.change_fee(instance, value)
|
||||||
|
|
||||||
|
if self.context.get('commit', True):
|
||||||
|
ocm.commit()
|
||||||
|
instance.refresh_from_db()
|
||||||
|
except OrderError as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class PatchFeeSerializer(serializers.Serializer):
|
||||||
|
fee = serializers.PrimaryKeyRelatedField(queryset=OrderFee.all.none())
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['fee'].queryset = self.context['order'].fees.all()
|
||||||
|
self.fields['body'] = OrderFeeChangeSerializer(context=self.context)
|
||||||
|
|
||||||
|
|
||||||
|
class SelectFeeSerializer(serializers.Serializer):
|
||||||
|
fee = serializers.PrimaryKeyRelatedField(queryset=OrderFee.all.none())
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if not self.context:
|
||||||
|
return
|
||||||
|
self.fields['fee'].queryset = self.context['order'].fees.all()
|
||||||
|
|
||||||
|
|
||||||
|
class OrderChangeOperationSerializer(serializers.Serializer):
|
||||||
|
send_email = serializers.BooleanField(default=False, required=False)
|
||||||
|
reissue_invoice = serializers.BooleanField(default=True, required=False)
|
||||||
|
recalculate_taxes = serializers.ChoiceField(default=None, allow_null=True, required=False, choices=[
|
||||||
|
('keep_net', 'keep_net'),
|
||||||
|
('keep_gross', 'keep_gross'),
|
||||||
|
])
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(self, *args, **kwargs)
|
||||||
|
self.fields['patch_positions'] = PatchPositionSerializer(
|
||||||
|
many=True, required=False, context=self.context
|
||||||
|
)
|
||||||
|
self.fields['cancel_positions'] = SelectPositionSerializer(
|
||||||
|
many=True, required=False, context=self.context
|
||||||
|
)
|
||||||
|
self.fields['create_positions'] = OrderPositionCreateForExistingOrderSerializer(
|
||||||
|
many=True, required=False, context=self.context
|
||||||
|
)
|
||||||
|
self.fields['split_positions'] = SelectPositionSerializer(
|
||||||
|
many=True, required=False, context=self.context
|
||||||
|
)
|
||||||
|
self.fields['patch_fees'] = PatchFeeSerializer(
|
||||||
|
many=True, required=False, context=self.context
|
||||||
|
)
|
||||||
|
self.fields['cancel_fees'] = SelectFeeSerializer(
|
||||||
|
many=True, required=False, context=self.context
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
seen_positions = set()
|
||||||
|
for d in data.get('patch_positions', []):
|
||||||
|
print(d, seen_positions)
|
||||||
|
if d['position'] in seen_positions:
|
||||||
|
raise ValidationError({'patch_positions': ['You have specified the same object twice.']})
|
||||||
|
seen_positions.add(d['position'])
|
||||||
|
seen_positions = set()
|
||||||
|
for d in data.get('cancel_positions', []):
|
||||||
|
if d['position'] in seen_positions:
|
||||||
|
raise ValidationError({'cancel_positions': ['You have specified the same object twice.']})
|
||||||
|
seen_positions.add(d['position'])
|
||||||
|
seen_positions = set()
|
||||||
|
for d in data.get('split_positions', []):
|
||||||
|
if d['position'] in seen_positions:
|
||||||
|
raise ValidationError({'split_positions': ['You have specified the same object twice.']})
|
||||||
|
seen_positions.add(d['position'])
|
||||||
|
seen_fees = set()
|
||||||
|
for d in data.get('patch_fees', []):
|
||||||
|
if d['fee'] in seen_fees:
|
||||||
|
raise ValidationError({'patch_fees': ['You have specified the same object twice.']})
|
||||||
|
seen_positions.add(d['fee'])
|
||||||
|
seen_fees = set()
|
||||||
|
for d in data.get('cancel_fees', []):
|
||||||
|
if d['fee'] in seen_fees:
|
||||||
|
raise ValidationError({'cancel_fees': ['You have specified the same object twice.']})
|
||||||
|
seen_positions.add(d['fee'])
|
||||||
|
|
||||||
|
return data
|
||||||
@@ -296,7 +296,14 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
|||||||
'theme_round_borders',
|
'theme_round_borders',
|
||||||
'primary_font',
|
'primary_font',
|
||||||
'organizer_logo_image_inherit',
|
'organizer_logo_image_inherit',
|
||||||
'organizer_logo_image'
|
'organizer_logo_image',
|
||||||
|
'privacy_url',
|
||||||
|
'cookie_consent',
|
||||||
|
'cookie_consent_dialog_title',
|
||||||
|
'cookie_consent_dialog_text',
|
||||||
|
'cookie_consent_dialog_text_secondary',
|
||||||
|
'cookie_consent_dialog_button_yes',
|
||||||
|
'cookie_consent_dialog_button_no',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -408,6 +408,11 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
|
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
|
||||||
nonce = self.request.data.get('nonce')
|
nonce = self.request.data.get('nonce')
|
||||||
|
|
||||||
|
untrusted_input = (
|
||||||
|
self.request.GET.get('untrusted_input', '') not in ('0', 'false', 'False', '')
|
||||||
|
or (isinstance(self.request.auth, Device) and 'pretixscan' in (self.request.auth.software_brand or '').lower())
|
||||||
|
)
|
||||||
|
|
||||||
if 'datetime' in self.request.data:
|
if 'datetime' in self.request.data:
|
||||||
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
|
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
|
||||||
else:
|
else:
|
||||||
@@ -427,7 +432,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
|
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
|
||||||
if self.kwargs['pk'].isnumeric():
|
if self.kwargs['pk'].isnumeric() and not untrusted_input:
|
||||||
op = queryset.get(Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
op = queryset.get(Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
||||||
else:
|
else:
|
||||||
# In application/x-www-form-urlencoded, you can encodes space ' ' with '+' instead of '%20'.
|
# In application/x-www-form-urlencoded, you can encodes space ' ' with '+' instead of '%20'.
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class InitializationRequestSerializer(serializers.Serializer):
|
|||||||
hardware_model = serializers.CharField(max_length=190)
|
hardware_model = serializers.CharField(max_length=190)
|
||||||
software_brand = serializers.CharField(max_length=190)
|
software_brand = serializers.CharField(max_length=190)
|
||||||
software_version = serializers.CharField(max_length=190)
|
software_version = serializers.CharField(max_length=190)
|
||||||
|
info = serializers.JSONField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
class UpdateRequestSerializer(serializers.Serializer):
|
class UpdateRequestSerializer(serializers.Serializer):
|
||||||
@@ -49,6 +50,7 @@ class UpdateRequestSerializer(serializers.Serializer):
|
|||||||
hardware_model = serializers.CharField(max_length=190)
|
hardware_model = serializers.CharField(max_length=190)
|
||||||
software_brand = serializers.CharField(max_length=190)
|
software_brand = serializers.CharField(max_length=190)
|
||||||
software_version = serializers.CharField(max_length=190)
|
software_version = serializers.CharField(max_length=190)
|
||||||
|
info = serializers.JSONField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
class GateSerializer(serializers.ModelSerializer):
|
class GateSerializer(serializers.ModelSerializer):
|
||||||
@@ -94,6 +96,7 @@ class InitializeView(APIView):
|
|||||||
device.hardware_model = serializer.validated_data.get('hardware_model')
|
device.hardware_model = serializer.validated_data.get('hardware_model')
|
||||||
device.software_brand = serializer.validated_data.get('software_brand')
|
device.software_brand = serializer.validated_data.get('software_brand')
|
||||||
device.software_version = serializer.validated_data.get('software_version')
|
device.software_version = serializer.validated_data.get('software_version')
|
||||||
|
device.info = serializer.validated_data.get('info')
|
||||||
device.api_token = generate_api_token()
|
device.api_token = generate_api_token()
|
||||||
device.save()
|
device.save()
|
||||||
|
|
||||||
@@ -114,6 +117,7 @@ class UpdateView(APIView):
|
|||||||
device.hardware_model = serializer.validated_data.get('hardware_model')
|
device.hardware_model = serializer.validated_data.get('hardware_model')
|
||||||
device.software_brand = serializer.validated_data.get('software_brand')
|
device.software_brand = serializer.validated_data.get('software_brand')
|
||||||
device.software_version = serializer.validated_data.get('software_version')
|
device.software_version = serializer.validated_data.get('software_version')
|
||||||
|
device.info = serializer.validated_data.get('info')
|
||||||
device.save()
|
device.save()
|
||||||
device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device)
|
device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device)
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
|||||||
def exporters(self):
|
def exporters(self):
|
||||||
exporters = []
|
exporters = []
|
||||||
responses = register_data_exporters.send(self.request.event)
|
responses = register_data_exporters.send(self.request.event)
|
||||||
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
|
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)):
|
||||||
ex._serializer = JobRunSerializer(exporter=ex)
|
ex._serializer = JobRunSerializer(exporter=ex)
|
||||||
exporters.append(ex)
|
exporters.append(ex)
|
||||||
return exporters
|
return exporters
|
||||||
@@ -147,7 +147,11 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
|||||||
@cached_property
|
@cached_property
|
||||||
def exporters(self):
|
def exporters(self):
|
||||||
exporters = []
|
exporters = []
|
||||||
events = (self.request.auth or self.request.user).get_events_with_permission('can_view_orders', request=self.request).filter(
|
if isinstance(self.request.auth, (Device, TeamAPIToken)):
|
||||||
|
perm_holder = self.request.auth
|
||||||
|
else:
|
||||||
|
perm_holder = self.request.user
|
||||||
|
events = perm_holder.get_events_with_permission('can_view_orders', request=self.request).filter(
|
||||||
organizer=self.request.organizer
|
organizer=self.request.organizer
|
||||||
)
|
)
|
||||||
responses = register_multievent_data_exporters.send(self.request.organizer)
|
responses = register_multievent_data_exporters.send(self.request.organizer)
|
||||||
@@ -157,8 +161,12 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
|||||||
return exporters
|
return exporters
|
||||||
|
|
||||||
def get_serializer_kwargs(self):
|
def get_serializer_kwargs(self):
|
||||||
|
if isinstance(self.request.auth, (Device, TeamAPIToken)):
|
||||||
|
perm_holder = self.request.auth
|
||||||
|
else:
|
||||||
|
perm_holder = self.request.user
|
||||||
return {
|
return {
|
||||||
'events': self.request.auth.get_events_with_permission('can_view_orders', request=self.request).filter(
|
'events': perm_holder.get_events_with_permission('can_view_orders', request=self.request).filter(
|
||||||
organizer=self.request.organizer
|
organizer=self.request.organizer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from django.utils.translation import gettext as _
|
|||||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from rest_framework import mixins, serializers, status, viewsets
|
from rest_framework import serializers, status, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import (
|
from rest_framework.exceptions import (
|
||||||
APIException, NotFound, PermissionDenied, ValidationError,
|
APIException, NotFound, PermissionDenied, ValidationError,
|
||||||
@@ -53,6 +53,12 @@ from pretix.api.serializers.order import (
|
|||||||
PriceCalcSerializer, RevokedTicketSecretSerializer,
|
PriceCalcSerializer, RevokedTicketSecretSerializer,
|
||||||
SimulatedOrderSerializer,
|
SimulatedOrderSerializer,
|
||||||
)
|
)
|
||||||
|
from pretix.api.serializers.orderchange import (
|
||||||
|
OrderChangeOperationSerializer, OrderFeeChangeSerializer,
|
||||||
|
OrderPositionChangeSerializer,
|
||||||
|
OrderPositionCreateForExistingOrderSerializer,
|
||||||
|
OrderPositionInfoPatchSerializer,
|
||||||
|
)
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CachedCombinedTicket, CachedTicket, Checkin, Device, Event, Invoice,
|
CachedCombinedTicket, CachedTicket, Checkin, Device, Event, Invoice,
|
||||||
@@ -94,6 +100,7 @@ with scopes_disabled():
|
|||||||
search = django_filters.CharFilter(method='search_qs')
|
search = django_filters.CharFilter(method='search_qs')
|
||||||
item = django_filters.CharFilter(field_name='all_positions', lookup_expr='item_id')
|
item = django_filters.CharFilter(field_name='all_positions', lookup_expr='item_id')
|
||||||
variation = django_filters.CharFilter(field_name='all_positions', lookup_expr='variation_id')
|
variation = django_filters.CharFilter(field_name='all_positions', lookup_expr='variation_id')
|
||||||
|
subevent = django_filters.CharFilter(field_name='all_positions', lookup_expr='subevent_id')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
@@ -143,7 +150,8 @@ with scopes_disabled():
|
|||||||
matching_positions = OrderPosition.objects.filter(
|
matching_positions = OrderPosition.objects.filter(
|
||||||
Q(order=OuterRef('pk')) & Q(
|
Q(order=OuterRef('pk')) & Q(
|
||||||
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
|
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
|
||||||
| Q(secret__istartswith=u) | Q(voucher__code__icontains=u)
|
| Q(secret__istartswith=u)
|
||||||
|
# | Q(voucher__code__icontains=u) # temporarily removed since it caused bad query performance on postgres
|
||||||
)
|
)
|
||||||
).values('id')
|
).values('id')
|
||||||
|
|
||||||
@@ -337,6 +345,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
|||||||
@action(detail=True, methods=['POST'])
|
@action(detail=True, methods=['POST'])
|
||||||
def mark_canceled(self, request, **kwargs):
|
def mark_canceled(self, request, **kwargs):
|
||||||
send_mail = request.data.get('send_email', True)
|
send_mail = request.data.get('send_email', True)
|
||||||
|
comment = request.data.get('comment', None)
|
||||||
cancellation_fee = request.data.get('cancellation_fee', None)
|
cancellation_fee = request.data.get('cancellation_fee', None)
|
||||||
if cancellation_fee:
|
if cancellation_fee:
|
||||||
try:
|
try:
|
||||||
@@ -359,6 +368,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
|||||||
device=request.auth if isinstance(request.auth, Device) else None,
|
device=request.auth if isinstance(request.auth, Device) else None,
|
||||||
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
||||||
send_mail=send_mail,
|
send_mail=send_mail,
|
||||||
|
email_comment=comment,
|
||||||
cancellation_fee=cancellation_fee
|
cancellation_fee=cancellation_fee
|
||||||
)
|
)
|
||||||
except OrderError as e:
|
except OrderError as e:
|
||||||
@@ -643,9 +653,13 @@ class OrderViewSet(viewsets.ModelViewSet):
|
|||||||
if send_mail:
|
if send_mail:
|
||||||
free_flow = (
|
free_flow = (
|
||||||
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
|
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
|
||||||
not order.require_approval and payment.provider == "free"
|
not order.require_approval and payment.provider in ("free", "boxoffice")
|
||||||
)
|
)
|
||||||
if free_flow:
|
if order.require_approval:
|
||||||
|
email_template = request.event.settings.mail_text_order_placed_require_approval
|
||||||
|
log_entry = 'pretix.event.order.email.order_placed_require_approval'
|
||||||
|
email_attendees = False
|
||||||
|
elif free_flow:
|
||||||
email_template = request.event.settings.mail_text_order_free
|
email_template = request.event.settings.mail_text_order_free
|
||||||
log_entry = 'pretix.event.order.email.order_free'
|
log_entry = 'pretix.event.order.email.order_free'
|
||||||
email_attendees = request.event.settings.mail_send_order_free_attendee
|
email_attendees = request.event.settings.mail_send_order_free_attendee
|
||||||
@@ -658,12 +672,13 @@ class OrderViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
_order_placed_email(
|
_order_placed_email(
|
||||||
request.event, order, payment.payment_provider if payment else None, email_template,
|
request.event, order, payment.payment_provider if payment else None, email_template,
|
||||||
log_entry, invoice, payment
|
log_entry, invoice, payment, is_free=free_flow
|
||||||
)
|
)
|
||||||
if email_attendees:
|
if email_attendees:
|
||||||
for p in order.positions.all():
|
for p in order.positions.all():
|
||||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||||
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry)
|
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry,
|
||||||
|
is_free=free_flow)
|
||||||
|
|
||||||
if not free_flow and order.status == Order.STATUS_PAID and payment:
|
if not free_flow and order.status == Order.STATUS_PAID and payment:
|
||||||
payment._send_paid_mail(invoice, None, '')
|
payment._send_paid_mail(invoice, None, '')
|
||||||
@@ -776,6 +791,79 @@ class OrderViewSet(viewsets.ModelViewSet):
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['POST'])
|
||||||
|
def change(self, request, **kwargs):
|
||||||
|
order = self.get_object()
|
||||||
|
|
||||||
|
serializer = OrderChangeOperationSerializer(
|
||||||
|
context={'order': order, **self.get_serializer_context()},
|
||||||
|
data=request.data,
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ocm = OrderChangeManager(
|
||||||
|
order=order,
|
||||||
|
user=self.request.user if self.request.user.is_authenticated else None,
|
||||||
|
auth=request.auth,
|
||||||
|
notify=serializer.validated_data.get('send_email', False),
|
||||||
|
reissue_invoice=serializer.validated_data.get('reissue_invoice', True),
|
||||||
|
)
|
||||||
|
|
||||||
|
canceled_positions = set()
|
||||||
|
for r in serializer.validated_data.get('cancel_positions', []):
|
||||||
|
ocm.cancel(r['position'])
|
||||||
|
canceled_positions.add(r['position'])
|
||||||
|
|
||||||
|
for r in serializer.validated_data.get('patch_positions', []):
|
||||||
|
if r['position'] in canceled_positions:
|
||||||
|
continue
|
||||||
|
pos_serializer = OrderPositionChangeSerializer(
|
||||||
|
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
|
||||||
|
partial=True,
|
||||||
|
)
|
||||||
|
pos_serializer.update(r['position'], r['body'])
|
||||||
|
|
||||||
|
for r in serializer.validated_data.get('split_positions', []):
|
||||||
|
if r['position'] in canceled_positions:
|
||||||
|
continue
|
||||||
|
ocm.split(r['position'])
|
||||||
|
|
||||||
|
for r in serializer.validated_data.get('create_positions', []):
|
||||||
|
pos_serializer = OrderPositionCreateForExistingOrderSerializer(
|
||||||
|
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
|
||||||
|
)
|
||||||
|
pos_serializer.create(r)
|
||||||
|
|
||||||
|
canceled_fees = set()
|
||||||
|
for r in serializer.validated_data.get('cancel_fees', []):
|
||||||
|
ocm.cancel_fee(r['fee'])
|
||||||
|
canceled_fees.add(r['fee'])
|
||||||
|
|
||||||
|
for r in serializer.validated_data.get('patch_fees', []):
|
||||||
|
if r['fee'] in canceled_fees:
|
||||||
|
continue
|
||||||
|
pos_serializer = OrderFeeChangeSerializer(
|
||||||
|
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
|
||||||
|
)
|
||||||
|
pos_serializer.update(r['fee'], r['body'])
|
||||||
|
|
||||||
|
if serializer.validated_data.get('recalculate_taxes') == 'keep_net':
|
||||||
|
ocm.recalculate_taxes(keep='net')
|
||||||
|
elif serializer.validated_data.get('recalculate_taxes') == 'keep_gross':
|
||||||
|
ocm.recalculate_taxes(keep='gross')
|
||||||
|
|
||||||
|
ocm.commit()
|
||||||
|
except OrderError as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
|
||||||
|
order.refresh_from_db()
|
||||||
|
serializer = OrderSerializer(
|
||||||
|
instance=order,
|
||||||
|
context=self.get_serializer_context(),
|
||||||
|
)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
class OrderPositionFilter(FilterSet):
|
class OrderPositionFilter(FilterSet):
|
||||||
@@ -817,7 +905,7 @@ with scopes_disabled():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
|
class OrderPositionViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = OrderPositionSerializer
|
serializer_class = OrderPositionSerializer
|
||||||
queryset = OrderPosition.all.none()
|
queryset = OrderPosition.all.none()
|
||||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||||
@@ -1054,6 +1142,25 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
|
|||||||
)
|
)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
@action(detail=True, methods=['POST'])
|
||||||
|
def regenerate_secrets(self, request, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
try:
|
||||||
|
ocm = OrderChangeManager(
|
||||||
|
instance.order,
|
||||||
|
user=self.request.user if self.request.user.is_authenticated else None,
|
||||||
|
auth=self.request.auth,
|
||||||
|
notify=False,
|
||||||
|
reissue_invoice=False,
|
||||||
|
)
|
||||||
|
ocm.regenerate_secret(instance)
|
||||||
|
ocm.commit()
|
||||||
|
except OrderError as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
except Quota.QuotaExceededException as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
try:
|
try:
|
||||||
ocm = OrderChangeManager(
|
ocm = OrderChangeManager(
|
||||||
@@ -1069,18 +1176,33 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
|
|||||||
except Quota.QuotaExceededException as e:
|
except Quota.QuotaExceededException as e:
|
||||||
raise ValidationError(str(e))
|
raise ValidationError(str(e))
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
partial = kwargs.get('partial', False)
|
|
||||||
if not partial:
|
|
||||||
return Response(
|
|
||||||
{"detail": "Method \"PUT\" not allowed."},
|
|
||||||
status=status.HTTP_405_METHOD_NOT_ALLOWED,
|
|
||||||
)
|
|
||||||
return super().update(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
old_data = self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data
|
serializer = OrderPositionCreateForExistingOrderSerializer(
|
||||||
|
data=request.data,
|
||||||
|
context=self.get_serializer_context(),
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
order = serializer.validated_data['order']
|
||||||
|
ocm = OrderChangeManager(
|
||||||
|
order=order,
|
||||||
|
user=self.request.user if self.request.user.is_authenticated else None,
|
||||||
|
auth=request.auth,
|
||||||
|
notify=False,
|
||||||
|
reissue_invoice=False,
|
||||||
|
)
|
||||||
|
serializer.context['ocm'] = ocm
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
# Fields that can be easily patched after the position was added
|
||||||
|
old_data = OrderPositionInfoPatchSerializer(instance=serializer.instance, context=self.get_serializer_context()).data
|
||||||
|
serializer = OrderPositionInfoPatchSerializer(
|
||||||
|
instance=serializer.instance,
|
||||||
|
context=self.get_serializer_context(),
|
||||||
|
partial=True,
|
||||||
|
data=request.data
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
serializer.save()
|
serializer.save()
|
||||||
new_data = serializer.data
|
new_data = serializer.data
|
||||||
|
|
||||||
@@ -1103,9 +1225,77 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
tickets.invalidate_cache.apply_async(
|
||||||
|
kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
|
||||||
|
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
|
||||||
|
return Response(
|
||||||
|
OrderPositionSerializer(serializer.instance, context=self.get_serializer_context()).data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
|
def update(self, request, *args, **kwargs):
|
||||||
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
|
partial = kwargs.get('partial', False)
|
||||||
|
if not partial:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Method \"PUT\" not allowed."},
|
||||||
|
status=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||||
|
)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
instance = self.get_object()
|
||||||
|
ocm = OrderChangeManager(
|
||||||
|
order=instance.order,
|
||||||
|
user=self.request.user if self.request.user.is_authenticated else None,
|
||||||
|
auth=request.auth,
|
||||||
|
notify=False,
|
||||||
|
reissue_invoice=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Field that need to go through OrderChangeManager
|
||||||
|
serializer = OrderPositionChangeSerializer(
|
||||||
|
instance=instance,
|
||||||
|
context={'ocm': ocm, **self.get_serializer_context()},
|
||||||
|
partial=True,
|
||||||
|
data=request.data
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
# Fields that can be easily patched
|
||||||
|
old_data = OrderPositionInfoPatchSerializer(instance=instance, context=self.get_serializer_context()).data
|
||||||
|
serializer = OrderPositionInfoPatchSerializer(
|
||||||
|
instance=instance,
|
||||||
|
context=self.get_serializer_context(),
|
||||||
|
partial=True,
|
||||||
|
data=request.data
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
new_data = serializer.data
|
||||||
|
|
||||||
|
if old_data != new_data:
|
||||||
|
log_data = self.request.data
|
||||||
|
if 'answers' in log_data:
|
||||||
|
for a in new_data['answers']:
|
||||||
|
log_data[f'question_{a["question"]}'] = a["answer"]
|
||||||
|
log_data.pop('answers', None)
|
||||||
|
serializer.instance.order.log_action(
|
||||||
|
'pretix.event.order.modified',
|
||||||
|
user=self.request.user,
|
||||||
|
auth=self.request.auth,
|
||||||
|
data={
|
||||||
|
'data': [
|
||||||
|
dict(
|
||||||
|
position=serializer.instance.pk,
|
||||||
|
**log_data
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
|
||||||
|
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
|
||||||
|
|
||||||
|
return Response(self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data)
|
||||||
|
|
||||||
|
|
||||||
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ class BaseAuthBackend:
|
|||||||
This method will be called after the user filled in the login form. ``request`` will contain
|
This method will be called after the user filled in the login form. ``request`` will contain
|
||||||
the current request and ``form_data`` the input for the form fields defined in ``login_form_fields``.
|
the current request and ``form_data`` the input for the form fields defined in ``login_form_fields``.
|
||||||
You are expected to either return a ``User`` object (if login was successful) or ``None``.
|
You are expected to either return a ``User`` object (if login was successful) or ``None``.
|
||||||
|
|
||||||
|
You are expected to either return a ``User`` object (if login was successful) or ``None``. You should
|
||||||
|
obtain this user object using ``User.objects.get_or_create_for_backend``.
|
||||||
"""
|
"""
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -104,7 +107,9 @@ class BaseAuthBackend:
|
|||||||
reverse proxy, you can directly return a ``User`` object that will be logged in.
|
reverse proxy, you can directly return a ``User`` object that will be logged in.
|
||||||
|
|
||||||
``request`` will contain the current request.
|
``request`` will contain the current request.
|
||||||
You are expected to either return a ``User`` object (if login was successful) or ``None``.
|
|
||||||
|
You are expected to either return a ``User`` object (if login was successful) or ``None``. You should
|
||||||
|
obtain this user object using ``User.objects.get_or_create_for_backend``.
|
||||||
"""
|
"""
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -146,7 +151,8 @@ class NativeAuthBackend(BaseAuthBackend):
|
|||||||
d = OrderedDict([
|
d = OrderedDict([
|
||||||
('email', forms.EmailField(label=_("E-mail"), max_length=254,
|
('email', forms.EmailField(label=_("E-mail"), max_length=254,
|
||||||
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
|
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
|
||||||
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput)),
|
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput,
|
||||||
|
max_length=4096)),
|
||||||
])
|
])
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from datetime import timedelta
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
from smtplib import SMTPResponseException
|
from smtplib import SMTPResponseException
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import css_inline
|
import css_inline
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -32,6 +33,7 @@ from django.core.mail.backends.smtp import EmailBackend
|
|||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
|
from django.utils.formats import date_format
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import (
|
from django.utils.translation import (
|
||||||
get_language, gettext_lazy as _, pgettext_lazy,
|
get_language, gettext_lazy as _, pgettext_lazy,
|
||||||
@@ -49,23 +51,23 @@ from pretix.base.templatetags.rich_text import markdown_compile_email
|
|||||||
|
|
||||||
logger = logging.getLogger('pretix.base.email')
|
logger = logging.getLogger('pretix.base.email')
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=EmailBackend)
|
||||||
|
|
||||||
class CustomSMTPBackend(EmailBackend):
|
|
||||||
|
|
||||||
def test(self, from_addr):
|
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
|
||||||
try:
|
try:
|
||||||
self.open()
|
backend.open()
|
||||||
self.connection.ehlo_or_helo_if_needed()
|
backend.connection.ehlo_or_helo_if_needed()
|
||||||
(code, resp) = self.connection.mail(from_addr, [])
|
(code, resp) = backend.connection.mail(from_addr, [])
|
||||||
if code != 250:
|
if code != 250:
|
||||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
logger.warning('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||||
raise SMTPResponseException(code, resp)
|
raise SMTPResponseException(code, resp)
|
||||||
(code, resp) = self.connection.rcpt('testdummy@pretix.eu')
|
(code, resp) = backend.connection.rcpt('testdummy@pretix.eu')
|
||||||
if (code != 250) and (code != 251):
|
if (code != 250) and (code != 251):
|
||||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
logger.warning('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||||
raise SMTPResponseException(code, resp)
|
raise SMTPResponseException(code, resp)
|
||||||
finally:
|
finally:
|
||||||
self.close()
|
backend.close()
|
||||||
|
|
||||||
|
|
||||||
class BaseHTMLMailRenderer:
|
class BaseHTMLMailRenderer:
|
||||||
@@ -163,9 +165,20 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
|||||||
has_addons=Count('addons')
|
has_addons=Count('addons')
|
||||||
))
|
))
|
||||||
htmlctx['cart'] = [(k, list(v)) for k, v in groupby(
|
htmlctx['cart'] = [(k, list(v)) for k, v in groupby(
|
||||||
positions, key=lambda op: (
|
sorted(
|
||||||
op.item, op.variation, op.subevent, op.attendee_name,
|
positions,
|
||||||
(op.pk if op.addon_to_id else None), (op.pk if op.has_addons else None)
|
key=lambda op: (
|
||||||
|
(op.addon_to.positionid if op.addon_to_id else op.positionid),
|
||||||
|
op.positionid
|
||||||
|
)
|
||||||
|
),
|
||||||
|
key=lambda op: (
|
||||||
|
op.item,
|
||||||
|
op.variation,
|
||||||
|
op.subevent,
|
||||||
|
op.attendee_name,
|
||||||
|
op.addon_to_id,
|
||||||
|
(op.pk if op.has_addons else None)
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
|
|
||||||
@@ -297,7 +310,11 @@ def get_email_context(**kwargs):
|
|||||||
val = [val]
|
val = [val]
|
||||||
for v in val:
|
for v in val:
|
||||||
if all(rp in kwargs for rp in v.required_context):
|
if all(rp in kwargs for rp in v.required_context):
|
||||||
ctx[v.identifier] = v.render(kwargs)
|
try:
|
||||||
|
ctx[v.identifier] = v.render(kwargs)
|
||||||
|
except:
|
||||||
|
ctx[v.identifier] = '(error)'
|
||||||
|
logger.exception(f'Failed to process email placeholder {v.identifier}.')
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@@ -452,6 +469,15 @@ def base_placeholders(sender, **kwargs):
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SimpleFunctionalMailTextPlaceholder(
|
||||||
|
'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''),
|
||||||
|
lambda event: str(event.location or ''),
|
||||||
|
),
|
||||||
|
SimpleFunctionalMailTextPlaceholder(
|
||||||
|
'event_admission_time', ['event_or_subevent'],
|
||||||
|
lambda event_or_subevent: date_format(event_or_subevent.date_admission, 'TIME_FORMAT') if event_or_subevent.date_admission else '',
|
||||||
|
lambda event: date_format(event.date_admission, 'TIME_FORMAT') if event.date_admission else '',
|
||||||
|
),
|
||||||
SimpleFunctionalMailTextPlaceholder(
|
SimpleFunctionalMailTextPlaceholder(
|
||||||
'subevent', ['waiting_list_entry', 'event'],
|
'subevent', ['waiting_list_entry', 'event'],
|
||||||
lambda waiting_list_entry, event: str(waiting_list_entry.subevent or event),
|
lambda waiting_list_entry, event: str(waiting_list_entry.subevent or event),
|
||||||
@@ -621,6 +647,10 @@ def base_placeholders(sender, **kwargs):
|
|||||||
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
|
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
|
||||||
v
|
v
|
||||||
))
|
))
|
||||||
|
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||||
|
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
|
||||||
|
v
|
||||||
|
))
|
||||||
|
|
||||||
return ph
|
return ph
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import re
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections import OrderedDict, namedtuple
|
from collections import OrderedDict, namedtuple
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -46,26 +45,13 @@ from django.conf import settings
|
|||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.utils.formats import localize
|
from django.utils.formats import localize
|
||||||
from django.utils.translation import gettext, gettext_lazy as _
|
from django.utils.translation import gettext, gettext_lazy as _
|
||||||
from openpyxl import Workbook
|
|
||||||
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES, Cell
|
|
||||||
|
|
||||||
from pretix.base.models import Event
|
from pretix.base.models import Event
|
||||||
|
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
|
||||||
|
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
|
||||||
|
)
|
||||||
|
|
||||||
|
__ = excel_safe # just so the compatbility import above is "used" and doesn't get removed by linter
|
||||||
def excel_safe(val):
|
|
||||||
if isinstance(val, Cell):
|
|
||||||
return val
|
|
||||||
|
|
||||||
if not isinstance(val, KNOWN_TYPES):
|
|
||||||
val = str(val)
|
|
||||||
|
|
||||||
if isinstance(val, bytes):
|
|
||||||
val = val.decode("utf-8", errors="ignore")
|
|
||||||
|
|
||||||
if isinstance(val, str):
|
|
||||||
val = re.sub(ILLEGAL_CHARACTERS_RE, '', val)
|
|
||||||
|
|
||||||
return val
|
|
||||||
|
|
||||||
|
|
||||||
class BaseExporter:
|
class BaseExporter:
|
||||||
@@ -228,7 +214,7 @@ class ListExporter(BaseExporter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _render_xlsx(self, form_data, output_file=None):
|
def _render_xlsx(self, form_data, output_file=None):
|
||||||
wb = Workbook(write_only=True)
|
wb = SafeWorkbook(write_only=True)
|
||||||
ws = wb.create_sheet()
|
ws = wb.create_sheet()
|
||||||
self.prepare_xlsx_sheet(ws)
|
self.prepare_xlsx_sheet(ws)
|
||||||
try:
|
try:
|
||||||
@@ -242,7 +228,7 @@ class ListExporter(BaseExporter):
|
|||||||
total = line.total
|
total = line.total
|
||||||
continue
|
continue
|
||||||
ws.append([
|
ws.append([
|
||||||
excel_safe(val) for val in line
|
val for val in line
|
||||||
])
|
])
|
||||||
if total:
|
if total:
|
||||||
counter += 1
|
counter += 1
|
||||||
@@ -347,7 +333,7 @@ class MultiSheetListExporter(ListExporter):
|
|||||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||||
|
|
||||||
def _render_xlsx(self, form_data, output_file=None):
|
def _render_xlsx(self, form_data, output_file=None):
|
||||||
wb = Workbook(write_only=True)
|
wb = SafeWorkbook(write_only=True)
|
||||||
n_sheets = len(self.sheets)
|
n_sheets = len(self.sheets)
|
||||||
for i_sheet, (s, l) in enumerate(self.sheets):
|
for i_sheet, (s, l) in enumerate(self.sheets):
|
||||||
ws = wb.create_sheet(str(l))
|
ws = wb.create_sheet(str(l))
|
||||||
@@ -361,8 +347,7 @@ class MultiSheetListExporter(ListExporter):
|
|||||||
total = line.total
|
total = line.total
|
||||||
continue
|
continue
|
||||||
ws.append([
|
ws.append([
|
||||||
excel_safe(val)
|
val for val in line
|
||||||
for val in line
|
|
||||||
])
|
])
|
||||||
if total:
|
if total:
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
#
|
#
|
||||||
from .answers import * # noqa
|
from .answers import * # noqa
|
||||||
from .dekodi import * # noqa
|
from .dekodi import * # noqa
|
||||||
|
from .events import * # noqa
|
||||||
from .invoices import * # noqa
|
from .invoices import * # noqa
|
||||||
from .json import * # noqa
|
from .json import * # noqa
|
||||||
from .mail import * # noqa
|
from .mail import * # noqa
|
||||||
|
|||||||
100
src/pretix/base/exporters/events.py
Normal file
100
src/pretix/base/exporters/events.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
#
|
||||||
|
# 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/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
||||||
|
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||||
|
#
|
||||||
|
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
|
||||||
|
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
|
||||||
|
#
|
||||||
|
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||||
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.utils.formats import date_format
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from ...control.forms.filter import get_all_payment_providers
|
||||||
|
from ..exporter import ListExporter
|
||||||
|
from ..signals import register_multievent_data_exporters
|
||||||
|
|
||||||
|
|
||||||
|
class EventDataExporter(ListExporter):
|
||||||
|
identifier = 'eventdata'
|
||||||
|
verbose_name = _('Event data')
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def providers(self):
|
||||||
|
return dict(get_all_payment_providers())
|
||||||
|
|
||||||
|
def iterate_list(self, form_data):
|
||||||
|
header = [
|
||||||
|
_("Event name"),
|
||||||
|
_("Short form"),
|
||||||
|
_("Shop is live"),
|
||||||
|
_("Event currency"),
|
||||||
|
_("Event start time"),
|
||||||
|
_("Event end time"),
|
||||||
|
_("Admission time"),
|
||||||
|
_("Start of presale"),
|
||||||
|
_("End of presale"),
|
||||||
|
_("Location"),
|
||||||
|
_("Latitude"),
|
||||||
|
_("Longitude"),
|
||||||
|
_("Internal comment"),
|
||||||
|
]
|
||||||
|
props = list(self.organizer.meta_properties.all())
|
||||||
|
for p in props:
|
||||||
|
header.append(p.name)
|
||||||
|
yield header
|
||||||
|
|
||||||
|
for e in self.events.all():
|
||||||
|
m = e.meta_data
|
||||||
|
yield [
|
||||||
|
str(e.name),
|
||||||
|
e.slug,
|
||||||
|
_('Yes') if e.live else _('No'),
|
||||||
|
e.currency,
|
||||||
|
date_format(e.date_from, 'SHORT_DATETIME_FORMAT'),
|
||||||
|
date_format(e.date_to, 'SHORT_DATETIME_FORMAT') if e.date_to else '',
|
||||||
|
date_format(e.date_admission, 'SHORT_DATETIME_FORMAT') if e.date_admission else '',
|
||||||
|
date_format(e.presale_start, 'SHORT_DATETIME_FORMAT') if e.presale_start else '',
|
||||||
|
date_format(e.presale_end, 'SHORT_DATETIME_FORMAT') if e.presale_end else '',
|
||||||
|
str(e.location),
|
||||||
|
e.geo_lat or '',
|
||||||
|
e.geo_lon or '',
|
||||||
|
e.comment,
|
||||||
|
] + [
|
||||||
|
m.get(p.name, '') for p in props
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_filename(self):
|
||||||
|
return '{}_events'.format(self.events.first().organizer.slug)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_eventdata")
|
||||||
|
def register_multievent_eventdata_exporter(sender, **kwargs):
|
||||||
|
return EventDataExporter
|
||||||
@@ -573,6 +573,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
pgettext('address', 'State'),
|
pgettext('address', 'State'),
|
||||||
_('Voucher'),
|
_('Voucher'),
|
||||||
_('Pseudonymization ID'),
|
_('Pseudonymization ID'),
|
||||||
|
_('Ticket secret'),
|
||||||
_('Seat ID'),
|
_('Seat ID'),
|
||||||
_('Seat name'),
|
_('Seat name'),
|
||||||
_('Seat zone'),
|
_('Seat zone'),
|
||||||
@@ -669,6 +670,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
op.state or '',
|
op.state or '',
|
||||||
op.voucher.code if op.voucher else '',
|
op.voucher.code if op.voucher else '',
|
||||||
op.pseudonymization_id,
|
op.pseudonymization_id,
|
||||||
|
op.secret,
|
||||||
]
|
]
|
||||||
|
|
||||||
if op.seat:
|
if op.seat:
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import i18nfield.forms
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.forms.models import ModelFormMetaclass
|
from django.forms.models import ModelFormMetaclass
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from formtools.wizard.views import SessionWizardView
|
from formtools.wizard.views import SessionWizardView
|
||||||
from hierarkey.forms import HierarkeyForm
|
from hierarkey.forms import HierarkeyForm
|
||||||
|
|
||||||
@@ -112,12 +113,42 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
|||||||
if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
|
if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
|
||||||
f.set_event(self.obj)
|
f.set_event(self.obj)
|
||||||
|
|
||||||
def save(self):
|
def _unmask_secret_fields(self):
|
||||||
for k, v in self.cleaned_data.items():
|
for k, v in self.cleaned_data.items():
|
||||||
if isinstance(self.fields.get(k), SecretKeySettingsField) and self.cleaned_data.get(k) == SECRET_REDACTED:
|
if isinstance(self.fields.get(k), SecretKeySettingsField) and self.cleaned_data.get(k) == SECRET_REDACTED:
|
||||||
self.cleaned_data[k] = self.initial[k]
|
self.cleaned_data[k] = self.initial[k]
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
self._unmask_secret_fields()
|
||||||
return super().save()
|
return super().save()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
d = super().clean()
|
||||||
|
|
||||||
|
# There is logic in HierarkeyForm.save() to only persist fields that changed. HierarkeyForm determines if
|
||||||
|
# something changed by comparing `self._s.get(name)` to `value`. This leaves an edge case open for multi-lingual
|
||||||
|
# text fields. On the very first load, the initial value in `self._s.get(name)` will be a LazyGettextProxy-based
|
||||||
|
# string. However, only some of the languages are usually visible, so even if the user does not change anything
|
||||||
|
# at all, it will be considered a changed value and stored. We do not want that, as it makes it very hard to add
|
||||||
|
# languages to an organizer/event later on. So we trick it and make sure nothing gets changed in that situation.
|
||||||
|
for name, field in self.fields.items():
|
||||||
|
if isinstance(field, SecretKeySettingsField) and d.get(name) == SECRET_REDACTED and not self.initial.get(name):
|
||||||
|
self.add_error(
|
||||||
|
name,
|
||||||
|
_('Due to technical reasons you cannot set inputs, that need to be masked (e.g. passwords), to %(value)s.') % {'value': SECRET_REDACTED}
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(field, i18nfield.forms.I18nFormField):
|
||||||
|
value = d.get(name)
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current = self._s.get(name, as_type=type(value))
|
||||||
|
if name not in self.changed_data:
|
||||||
|
d[name] = current
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
def get_new_filename(self, name: str) -> str:
|
def get_new_filename(self, name: str) -> str:
|
||||||
from pretix.base.models import Event
|
from pretix.base.models import Event
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class RegistrationForm(forms.Form):
|
|||||||
widget=forms.PasswordInput(attrs={
|
widget=forms.PasswordInput(attrs={
|
||||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||||
}),
|
}),
|
||||||
|
max_length=4096,
|
||||||
required=True
|
required=True
|
||||||
)
|
)
|
||||||
password_repeat = forms.CharField(
|
password_repeat = forms.CharField(
|
||||||
@@ -161,6 +162,7 @@ class RegistrationForm(forms.Form):
|
|||||||
widget=forms.PasswordInput(attrs={
|
widget=forms.PasswordInput(attrs={
|
||||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||||
}),
|
}),
|
||||||
|
max_length=4096,
|
||||||
required=True
|
required=True
|
||||||
)
|
)
|
||||||
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
||||||
@@ -204,11 +206,13 @@ class PasswordRecoverForm(forms.Form):
|
|||||||
password = forms.CharField(
|
password = forms.CharField(
|
||||||
label=_('Password'),
|
label=_('Password'),
|
||||||
widget=forms.PasswordInput,
|
widget=forms.PasswordInput,
|
||||||
|
max_length=4096,
|
||||||
required=True
|
required=True
|
||||||
)
|
)
|
||||||
password_repeat = forms.CharField(
|
password_repeat = forms.CharField(
|
||||||
label=_('Repeat password'),
|
label=_('Repeat password'),
|
||||||
widget=forms.PasswordInput
|
widget=forms.PasswordInput,
|
||||||
|
max_length=4096,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, user_id=None, *args, **kwargs):
|
def __init__(self, user_id=None, *args, **kwargs):
|
||||||
|
|||||||
@@ -41,16 +41,16 @@ from io import BytesIO
|
|||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
import pycountry
|
import pycountry
|
||||||
import pytz
|
import pytz
|
||||||
from babel import Locale
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import (
|
||||||
|
MaxValueValidator, MinValueValidator, RegexValidator,
|
||||||
|
)
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.forms import Select, widgets
|
from django.forms import Select, widgets
|
||||||
from django.utils import translation
|
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
@@ -85,7 +85,9 @@ from pretix.base.templatetags.rich_text import rich_text
|
|||||||
from pretix.control.forms import (
|
from pretix.control.forms import (
|
||||||
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
|
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
|
||||||
)
|
)
|
||||||
from pretix.helpers.countries import CachedCountries
|
from pretix.helpers.countries import (
|
||||||
|
CachedCountries, get_phone_prefixes_sorted_and_localized,
|
||||||
|
)
|
||||||
from pretix.helpers.escapejson import escapejson_attr
|
from pretix.helpers.escapejson import escapejson_attr
|
||||||
from pretix.helpers.i18n import get_format_without_seconds
|
from pretix.helpers.i18n import get_format_without_seconds
|
||||||
from pretix.presale.signals import question_form_fields
|
from pretix.presale.signals import question_form_fields
|
||||||
@@ -187,6 +189,15 @@ class NamePartsFormField(forms.MultiValueField):
|
|||||||
defaults = {
|
defaults = {
|
||||||
'widget': self.widget,
|
'widget': self.widget,
|
||||||
'max_length': kwargs.pop('max_length', None),
|
'max_length': kwargs.pop('max_length', None),
|
||||||
|
'validators': [
|
||||||
|
RegexValidator(
|
||||||
|
# The following characters should never appear in a name anywhere of
|
||||||
|
# the world. However, they commonly appear in inputs generated by spam
|
||||||
|
# bots.
|
||||||
|
r'^[^$€/%§{}<>~]*$',
|
||||||
|
message=_('Please do not use special characters in names.')
|
||||||
|
)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
self.scheme_name = kwargs.pop('scheme')
|
self.scheme_name = kwargs.pop('scheme')
|
||||||
self.titles = kwargs.pop('titles')
|
self.titles = kwargs.pop('titles')
|
||||||
@@ -207,6 +218,7 @@ class NamePartsFormField(forms.MultiValueField):
|
|||||||
if fname == 'title' and self.scheme_titles:
|
if fname == 'title' and self.scheme_titles:
|
||||||
d = dict(defaults)
|
d = dict(defaults)
|
||||||
d.pop('max_length', None)
|
d.pop('max_length', None)
|
||||||
|
d.pop('validators', None)
|
||||||
field = forms.ChoiceField(
|
field = forms.ChoiceField(
|
||||||
**d,
|
**d,
|
||||||
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
|
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
|
||||||
@@ -215,6 +227,7 @@ class NamePartsFormField(forms.MultiValueField):
|
|||||||
elif fname == 'salutation':
|
elif fname == 'salutation':
|
||||||
d = dict(defaults)
|
d = dict(defaults)
|
||||||
d.pop('max_length', None)
|
d.pop('max_length', None)
|
||||||
|
d.pop('validators', None)
|
||||||
field = forms.ChoiceField(
|
field = forms.ChoiceField(
|
||||||
**d,
|
**d,
|
||||||
choices=[('', '---')] + PERSON_NAME_SALUTATIONS
|
choices=[('', '---')] + PERSON_NAME_SALUTATIONS
|
||||||
@@ -251,17 +264,14 @@ class WrappedPhonePrefixSelect(Select):
|
|||||||
|
|
||||||
def __init__(self, initial=None):
|
def __init__(self, initial=None):
|
||||||
choices = [("", "---------")]
|
choices = [("", "---------")]
|
||||||
language = get_babel_locale() # changed from default implementation that used the django locale
|
|
||||||
locale = Locale(translation.to_locale(language))
|
if initial:
|
||||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||||
prefix = "+%d" % prefix
|
if initial in values:
|
||||||
if initial and initial in values:
|
self.initial = "+%d" % prefix
|
||||||
self.initial = prefix
|
break
|
||||||
for country_code in values:
|
choices += get_phone_prefixes_sorted_and_localized()
|
||||||
country_name = locale.territories.get(country_code)
|
super().__init__(choices=choices, attrs={'aria-label': pgettext_lazy('phonenumber', 'International area code')})
|
||||||
if country_name:
|
|
||||||
choices.append((prefix, "{} {}".format(country_name, prefix)))
|
|
||||||
super().__init__(choices=sorted(choices, key=lambda item: item[1]), attrs={'aria-label': pgettext_lazy('phonenumber', 'International area code')})
|
|
||||||
|
|
||||||
def render(self, name, value, *args, **kwargs):
|
def render(self, name, value, *args, **kwargs):
|
||||||
return super().render(name, value or self.initial, *args, **kwargs)
|
return super().render(name, value or self.initial, *args, **kwargs)
|
||||||
@@ -305,7 +315,12 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
|||||||
silently deleting data.
|
silently deleting data.
|
||||||
"""
|
"""
|
||||||
if value:
|
if value:
|
||||||
if type(value) == PhoneNumber:
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = PhoneNumber.from_string(value)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
if isinstance(value, PhoneNumber):
|
||||||
if value.country_code and value.national_number:
|
if value.country_code and value.national_number:
|
||||||
return [
|
return [
|
||||||
"+%d" % value.country_code,
|
"+%d" % value.country_code,
|
||||||
@@ -333,23 +348,41 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
|||||||
def guess_country(event):
|
def guess_country(event):
|
||||||
# Try to guess the initial country from either the country of the merchant
|
# Try to guess the initial country from either the country of the merchant
|
||||||
# or the locale. This will hopefully save at least some users some scrolling :)
|
# or the locale. This will hopefully save at least some users some scrolling :)
|
||||||
locale = get_language_without_region()
|
|
||||||
country = event.settings.region or event.settings.invoice_address_from_country
|
country = event.settings.region or event.settings.invoice_address_from_country
|
||||||
if not country:
|
if not country:
|
||||||
valid_countries = countries.countries
|
country = get_country_by_locale(get_language_without_region())
|
||||||
if '-' in locale:
|
|
||||||
parts = locale.split('-')
|
|
||||||
# TODO: does this actually work?
|
|
||||||
if parts[1].upper() in valid_countries:
|
|
||||||
country = Country(parts[1].upper())
|
|
||||||
elif parts[0].upper() in valid_countries:
|
|
||||||
country = Country(parts[0].upper())
|
|
||||||
else:
|
|
||||||
if locale.upper() in valid_countries:
|
|
||||||
country = Country(locale.upper())
|
|
||||||
return country
|
return country
|
||||||
|
|
||||||
|
|
||||||
|
def get_country_by_locale(locale):
|
||||||
|
country = None
|
||||||
|
valid_countries = countries.countries
|
||||||
|
if '-' in locale:
|
||||||
|
parts = locale.split('-')
|
||||||
|
# TODO: does this actually work?
|
||||||
|
if parts[1].upper() in valid_countries:
|
||||||
|
country = Country(parts[1].upper())
|
||||||
|
elif parts[0].upper() in valid_countries:
|
||||||
|
country = Country(parts[0].upper())
|
||||||
|
else:
|
||||||
|
if locale.upper() in valid_countries:
|
||||||
|
country = Country(locale.upper())
|
||||||
|
return country
|
||||||
|
|
||||||
|
|
||||||
|
def guess_phone_prefix(event):
|
||||||
|
with language(get_babel_locale()):
|
||||||
|
country = str(guess_country(event))
|
||||||
|
return get_phone_prefix(country)
|
||||||
|
|
||||||
|
|
||||||
|
def get_phone_prefix(country):
|
||||||
|
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||||
|
if country in values:
|
||||||
|
return prefix
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
|
class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
|
||||||
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
|
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
|
||||||
|
|
||||||
@@ -674,7 +707,7 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
label=label, required=required,
|
label=label, required=required,
|
||||||
min_value=q.valid_number_min or Decimal('0.00'),
|
min_value=q.valid_number_min or Decimal('0.00'),
|
||||||
max_value=q.valid_number_max,
|
max_value=q.valid_number_max,
|
||||||
help_text=q.help_text,
|
help_text=help_text,
|
||||||
initial=initial.answer if initial else None,
|
initial=initial.answer if initial else None,
|
||||||
)
|
)
|
||||||
elif q.type == Question.TYPE_STRING:
|
elif q.type == Question.TYPE_STRING:
|
||||||
@@ -780,25 +813,26 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
if q.valid_datetime_max:
|
if q.valid_datetime_max:
|
||||||
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
||||||
elif q.type == Question.TYPE_PHONENUMBER:
|
elif q.type == Question.TYPE_PHONENUMBER:
|
||||||
with language(get_babel_locale()):
|
if initial:
|
||||||
default_country = guess_country(event)
|
|
||||||
default_prefix = None
|
|
||||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
|
||||||
if str(default_country) in values:
|
|
||||||
default_prefix = prefix
|
|
||||||
try:
|
try:
|
||||||
initial = PhoneNumber().from_string(initial.answer) if initial else "+{}.".format(default_prefix)
|
initial = PhoneNumber().from_string(initial.answer)
|
||||||
except NumberParseException:
|
except NumberParseException:
|
||||||
initial = None
|
initial = None
|
||||||
field = PhoneNumberField(
|
|
||||||
label=label, required=required,
|
if not initial:
|
||||||
help_text=help_text,
|
phone_prefix = guess_phone_prefix(event)
|
||||||
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
|
if phone_prefix:
|
||||||
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
|
initial = "+{}.".format(phone_prefix)
|
||||||
# the future.
|
|
||||||
initial=initial,
|
field = PhoneNumberField(
|
||||||
widget=WrappedPhoneNumberPrefixWidget()
|
label=label, required=required,
|
||||||
)
|
help_text=help_text,
|
||||||
|
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
|
||||||
|
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
|
||||||
|
# the future.
|
||||||
|
initial=initial,
|
||||||
|
widget=WrappedPhoneNumberPrefixWidget()
|
||||||
|
)
|
||||||
field.question = q
|
field.question = q
|
||||||
if answers:
|
if answers:
|
||||||
# Cache the answer object for later use
|
# Cache the answer object for later use
|
||||||
@@ -869,6 +903,12 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
if question_is_required(q) and not answer and answer != 0 and not field.errors:
|
if question_is_required(q) and not answer and answer != 0 and not field.errors:
|
||||||
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
|
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
|
||||||
|
|
||||||
|
# Strip invisible question from cleaned_data so they don't end up in the database
|
||||||
|
for q in question_cache.values():
|
||||||
|
answer = d.get('question_%d' % q.pk)
|
||||||
|
if q.dependency_question_id and not question_is_visible(q.dependency_question_id, q.dependency_values) and answer is not None:
|
||||||
|
d['question_%d' % q.pk] = None
|
||||||
|
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -1044,7 +1084,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
self.instance.vat_id_validated = True
|
self.instance.vat_id_validated = True
|
||||||
self.instance.vat_id = normalized_id
|
self.instance.vat_id = normalized_id
|
||||||
except VATIDFinalError as e:
|
except VATIDFinalError as e:
|
||||||
raise ValidationError(e.message)
|
if self.all_optional:
|
||||||
|
self.instance.vat_id_validated = False
|
||||||
|
messages.warning(self.request, e.message)
|
||||||
|
else:
|
||||||
|
raise ValidationError(e.message)
|
||||||
except VATIDTemporaryError as e:
|
except VATIDTemporaryError as e:
|
||||||
self.instance.vat_id_validated = False
|
self.instance.vat_id_validated = False
|
||||||
if self.request and self.vat_warning:
|
if self.request and self.vat_warning:
|
||||||
|
|||||||
@@ -42,6 +42,24 @@ from django.utils.timezone import get_current_timezone, now
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
def replace_arabic_numbers(inp):
|
||||||
|
if not isinstance(inp, str):
|
||||||
|
return inp
|
||||||
|
table = {
|
||||||
|
1632: 48, # 0
|
||||||
|
1633: 49, # 1
|
||||||
|
1634: 50, # 2
|
||||||
|
1635: 51, # 3
|
||||||
|
1636: 52, # 4
|
||||||
|
1637: 53, # 5
|
||||||
|
1638: 54, # 6
|
||||||
|
1639: 55, # 7
|
||||||
|
1640: 56, # 8
|
||||||
|
1641: 57, # 9
|
||||||
|
}
|
||||||
|
return inp.translate(table)
|
||||||
|
|
||||||
|
|
||||||
class DatePickerWidget(forms.DateInput):
|
class DatePickerWidget(forms.DateInput):
|
||||||
def __init__(self, attrs=None, date_format=None):
|
def __init__(self, attrs=None, date_format=None):
|
||||||
attrs = attrs or {}
|
attrs = attrs or {}
|
||||||
@@ -62,6 +80,10 @@ class DatePickerWidget(forms.DateInput):
|
|||||||
|
|
||||||
forms.DateInput.__init__(self, date_attrs, date_format)
|
forms.DateInput.__init__(self, date_attrs, date_format)
|
||||||
|
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
v = super().value_from_datadict(data, files, name)
|
||||||
|
return replace_arabic_numbers(v)
|
||||||
|
|
||||||
|
|
||||||
class TimePickerWidget(forms.TimeInput):
|
class TimePickerWidget(forms.TimeInput):
|
||||||
def __init__(self, attrs=None, time_format=None):
|
def __init__(self, attrs=None, time_format=None):
|
||||||
@@ -83,17 +105,13 @@ class TimePickerWidget(forms.TimeInput):
|
|||||||
|
|
||||||
forms.TimeInput.__init__(self, time_attrs, time_format)
|
forms.TimeInput.__init__(self, time_attrs, time_format)
|
||||||
|
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
v = super().value_from_datadict(data, files, name)
|
||||||
|
return replace_arabic_numbers(v)
|
||||||
|
|
||||||
|
|
||||||
class UploadedFileWidget(forms.ClearableFileInput):
|
class UploadedFileWidget(forms.ClearableFileInput):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# Browsers can't recognize that the server already has a file uploaded
|
|
||||||
# Don't mark this input as being required if we already have an answer
|
|
||||||
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
|
|
||||||
attrs = kwargs.get('attrs', {})
|
|
||||||
if kwargs.get('required') and kwargs.get('initial'):
|
|
||||||
attrs.update({'required': None})
|
|
||||||
kwargs.update({'attrs': attrs})
|
|
||||||
|
|
||||||
self.position = kwargs.pop('position')
|
self.position = kwargs.pop('position')
|
||||||
self.event = kwargs.pop('event')
|
self.event = kwargs.pop('event')
|
||||||
self.answer = kwargs.pop('answer')
|
self.answer = kwargs.pop('answer')
|
||||||
@@ -125,6 +143,15 @@ class UploadedFileWidget(forms.ClearableFileInput):
|
|||||||
'answer': self.answer.pk,
|
'answer': self.answer.pk,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def get_context(self, name, value, attrs):
|
||||||
|
# Browsers can't recognize that the server already has a file uploaded
|
||||||
|
# Don't mark this input as being required if we already have an answer
|
||||||
|
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
|
||||||
|
ctx = super().get_context(name, value, attrs)
|
||||||
|
if ctx['widget']['is_initial']:
|
||||||
|
ctx['widget']['attrs']['required'] = False
|
||||||
|
return ctx
|
||||||
|
|
||||||
def format_value(self, value):
|
def format_value(self, value):
|
||||||
if self.is_initial(value):
|
if self.is_initial(value):
|
||||||
return self.FakeFile(value, self.position, self.event, self.answer)
|
return self.FakeFile(value, self.position, self.event, self.answer)
|
||||||
@@ -178,6 +205,10 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
|||||||
# Skip one hierarchy level
|
# Skip one hierarchy level
|
||||||
forms.MultiWidget.__init__(self, widgets, attrs)
|
forms.MultiWidget.__init__(self, widgets, attrs)
|
||||||
|
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
v = super().value_from_datadict(data, files, name)
|
||||||
|
return [replace_arabic_numbers(i) for i in v]
|
||||||
|
|
||||||
|
|
||||||
class BusinessBooleanRadio(forms.RadioSelect):
|
class BusinessBooleanRadio(forms.RadioSelect):
|
||||||
def __init__(self, require_business=False, attrs=None):
|
def __init__(self, require_business=False, attrs=None):
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Case, F, OuterRef, Q, Subquery, Sum, Value, When
|
from django.db.models import (
|
||||||
|
Case, Count, F, OuterRef, Q, Subquery, Sum, Value, When,
|
||||||
|
)
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
@@ -45,6 +47,18 @@ class Command(BaseCommand):
|
|||||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
),
|
),
|
||||||
|
position_cnt=Case(
|
||||||
|
When(Q(status__in=('e', 'c')) | Q(require_approval=True), then=Value(0)),
|
||||||
|
default=Coalesce(
|
||||||
|
Subquery(
|
||||||
|
OrderPosition.objects.filter(
|
||||||
|
order=OuterRef('pk')
|
||||||
|
).order_by().values('order').annotate(p=Count('*')).values('p'),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
), Value(0), output_field=models.IntegerField()
|
||||||
|
),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
),
|
||||||
fee_total=Coalesce(
|
fee_total=Coalesce(
|
||||||
Subquery(
|
Subquery(
|
||||||
OrderFee.objects.filter(
|
OrderFee.objects.filter(
|
||||||
@@ -61,6 +75,15 @@ class Command(BaseCommand):
|
|||||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
),
|
),
|
||||||
|
tx_cnt=Coalesce(
|
||||||
|
Subquery(
|
||||||
|
Transaction.objects.filter(
|
||||||
|
order=OuterRef('pk'),
|
||||||
|
item__isnull=False,
|
||||||
|
).order_by().values('order').annotate(p=Sum(F('count'))).values('p'),
|
||||||
|
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
),
|
||||||
).annotate(
|
).annotate(
|
||||||
correct_total=Case(
|
correct_total=Case(
|
||||||
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
|
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
|
||||||
@@ -70,13 +93,15 @@ class Command(BaseCommand):
|
|||||||
),
|
),
|
||||||
).exclude(
|
).exclude(
|
||||||
total=F('position_total') + F('fee_total'),
|
total=F('position_total') + F('fee_total'),
|
||||||
tx_total=F('correct_total')
|
tx_total=F('correct_total'),
|
||||||
|
tx_cnt=F('position_cnt')
|
||||||
).select_related('event')
|
).select_related('event')
|
||||||
for o in qs:
|
for o in qs:
|
||||||
if abs(o.tx_total - o.correct_total) < Decimal('0.00001') and abs(o.position_total + o.fee_total - o.total) < Decimal('0.00001'):
|
if abs(o.tx_total - o.correct_total) < Decimal('0.00001') and abs(o.position_total + o.fee_total - o.total) < Decimal('0.00001') \
|
||||||
|
and o.tx_cnt == o.position_cnt:
|
||||||
# Ignore SQLite which treats Decimals like floats…
|
# Ignore SQLite which treats Decimals like floats…
|
||||||
continue
|
continue
|
||||||
print(f"Error in order {o.full_code}: status={o.status}, sum(positions)+sum(fees)={o.position_total + o.fee_total}, "
|
print(f"Error in order {o.full_code}: status={o.status}, sum(positions)+sum(fees)={o.position_total + o.fee_total}, "
|
||||||
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}")
|
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}, pos_cnt={o.position_cnt}, tx_pos_cnt={o.tx_cnt}")
|
||||||
|
|
||||||
self.stderr.write(self.style.SUCCESS(f'Check completed.'))
|
self.stderr.write(self.style.SUCCESS('Check completed.'))
|
||||||
|
|||||||
@@ -19,11 +19,13 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import connection
|
||||||
from django_scopes import scope, scopes_disabled
|
from django_scopes import scope, scopes_disabled
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +35,13 @@ class Command(BaseCommand):
|
|||||||
parser.parse_args = lambda x: parser.parse_known_args(x)[0]
|
parser.parse_args = lambda x: parser.parse_known_args(x)[0]
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--print-sql',
|
||||||
|
action='store_true',
|
||||||
|
help='Print all SQL queries.',
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
try:
|
try:
|
||||||
from django_extensions.management.commands import shell_plus # noqa
|
from django_extensions.management.commands import shell_plus # noqa
|
||||||
@@ -41,6 +50,11 @@ class Command(BaseCommand):
|
|||||||
cmd = 'shell'
|
cmd = 'shell'
|
||||||
del options['skip_checks']
|
del options['skip_checks']
|
||||||
|
|
||||||
|
if options['print_sql']:
|
||||||
|
connection.force_debug_cursor = True
|
||||||
|
logger = logging.getLogger("django.db.backends")
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
parser = self.create_parser(sys.argv[0], sys.argv[1])
|
parser = self.create_parser(sys.argv[0], sys.argv[1])
|
||||||
flags = parser.parse_known_args(sys.argv[2:])[1]
|
flags = parser.parse_known_args(sys.argv[2:])[1]
|
||||||
if "--override" in flags:
|
if "--override" in flags:
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django_mysql.checks import mysql_connections
|
from django_mysql.checks import mysql_connections
|
||||||
from django_mysql.utils import connection_is_mariadb
|
|
||||||
|
|
||||||
|
|
||||||
def set_attendee_name_parts(apps, schema_editor):
|
def set_attendee_name_parts(apps, schema_editor):
|
||||||
@@ -31,7 +30,7 @@ def check_mysqlversion(apps, schema_editor):
|
|||||||
conns = list(mysql_connections())
|
conns = list(mysql_connections())
|
||||||
found = 'Unknown version'
|
found = 'Unknown version'
|
||||||
for alias, conn in conns:
|
for alias, conn in conns:
|
||||||
if connection_is_mariadb(conn) and hasattr(conn, 'mysql_version'):
|
if hasattr(conn, 'mysql_is_mariadb') and conn.mysql_is_mariadb and hasattr(conn, 'mysql_version'):
|
||||||
if conn.mysql_version >= (10, 2, 7):
|
if conn.mysql_version >= (10, 2, 7):
|
||||||
any_conn_works = True
|
any_conn_works = True
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.9 on 2021-12-13 14:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0204_orderposition_backfill_is_bundled'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='itemvariation',
|
||||||
|
name='require_approval',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
src/pretix/base/migrations/0206_customer_phone.py
Normal file
19
src/pretix/base/migrations/0206_customer_phone.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 3.2.9 on 2022-01-12 10:59
|
||||||
|
|
||||||
|
import phonenumber_field.modelfields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0205_itemvariation_require_approval'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customer',
|
||||||
|
name='phone',
|
||||||
|
field=phonenumber_field.modelfields.PhoneNumberField(max_length=128, null=True, region=None),
|
||||||
|
),
|
||||||
|
]
|
||||||
23
src/pretix/base/migrations/0207_auto_20220119_1427.py
Normal file
23
src/pretix/base/migrations/0207_auto_20220119_1427.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2022-01-19 14:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0206_customer_phone'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='taxrule',
|
||||||
|
name='internal_name',
|
||||||
|
field=models.CharField(max_length=190, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='taxrule',
|
||||||
|
name='keep_gross_if_rate_changes',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
22
src/pretix/base/migrations/0208_auto_20220214_1632.py
Normal file
22
src/pretix/base/migrations/0208_auto_20220214_1632.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2022-02-14 16:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0207_auto_20220119_1427'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='auth_backend_identifier',
|
||||||
|
field=models.CharField(db_index=True, max_length=190, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='user',
|
||||||
|
unique_together={('auth_backend', 'auth_backend_identifier')},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
src/pretix/base/migrations/0209_device_info.py
Normal file
18
src/pretix/base/migrations/0209_device_info.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.12 on 2022-03-22 11:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0208_auto_20220214_1632'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='info',
|
||||||
|
field=models.JSONField(null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -44,7 +44,7 @@ from django.contrib.auth.models import (
|
|||||||
)
|
)
|
||||||
from django.contrib.auth.tokens import default_token_generator
|
from django.contrib.auth.tokens import default_token_generator
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import models
|
from django.db import IntegrityError, models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.crypto import get_random_string, salted_hmac
|
from django.utils.crypto import get_random_string, salted_hmac
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
@@ -61,6 +61,10 @@ from pretix.helpers.urls import build_absolute_uri
|
|||||||
from .base import LoggingMixin
|
from .base import LoggingMixin
|
||||||
|
|
||||||
|
|
||||||
|
class EmailAddressTakenError(IntegrityError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class UserManager(BaseUserManager):
|
class UserManager(BaseUserManager):
|
||||||
"""
|
"""
|
||||||
This is the user manager for our custom user model. See the User
|
This is the user manager for our custom user model. See the User
|
||||||
@@ -83,6 +87,116 @@ class UserManager(BaseUserManager):
|
|||||||
user.save()
|
user.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
def get_or_create_for_backend(self, backend, identifier, email, set_always, set_on_creation):
|
||||||
|
"""
|
||||||
|
This method should be used by third-party authentication backends to log in a user.
|
||||||
|
It either returns an already existing user or creates a new user.
|
||||||
|
|
||||||
|
In pretix 4.7 and earlier, email addresses were the only property to identify a user with.
|
||||||
|
Starting with pretix 4.8, backends SHOULD instead use a unique, immutable identifier
|
||||||
|
based on their backend data store to allow for changing email addresses.
|
||||||
|
|
||||||
|
This method transparently handles the conversion of old user accounts and adds the
|
||||||
|
backend identifier to their database record.
|
||||||
|
|
||||||
|
This method will never return users managed by a different authentication backend.
|
||||||
|
If you try to create an account with an email address already blocked by a different
|
||||||
|
authentication backend, :py:class:`EmailAddressTakenError` will be raised. In this case,
|
||||||
|
you should display a message to the user.
|
||||||
|
|
||||||
|
:param backend: The `identifier` attribute of the authentication backend
|
||||||
|
:param identifier: The unique, immutable identifier of this user, max. 190 characters
|
||||||
|
:param email: The user's email address
|
||||||
|
:param set_always: A dictionary of fields to update on the user model on every login
|
||||||
|
:param set_on_creation: A dictionary of fields to set on the user model if it's newly created
|
||||||
|
:return: A `User` instance.
|
||||||
|
"""
|
||||||
|
if identifier is None:
|
||||||
|
raise ValueError('You need to supply a custom, unique identifier for this user.')
|
||||||
|
if email is None:
|
||||||
|
raise ValueError('You need to supply an email address for this user.')
|
||||||
|
if 'auth_backend_identifier' in set_always or 'auth_backend_identifier' in set_on_creation or \
|
||||||
|
'auth_backend' in set_always or 'auth_backend' in set_on_creation:
|
||||||
|
raise ValueError('You may not update auth_backend/auth_backend_identifier.')
|
||||||
|
if len(identifier) > 190:
|
||||||
|
raise ValueError('The user identifier must not be more than 190 characters.')
|
||||||
|
|
||||||
|
# Always update the email address
|
||||||
|
set_always.update({'email': email})
|
||||||
|
|
||||||
|
# First, check if we find the user based on it's backend-specific authenticator
|
||||||
|
try:
|
||||||
|
u = self.get(
|
||||||
|
auth_backend=backend,
|
||||||
|
auth_backend_identifier=identifier,
|
||||||
|
)
|
||||||
|
dirty = False
|
||||||
|
for k, v in set_always.items():
|
||||||
|
if getattr(u, k) != v:
|
||||||
|
setattr(u, k, v)
|
||||||
|
dirty = True
|
||||||
|
if dirty:
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
u.save(update_fields=set_always.keys())
|
||||||
|
except IntegrityError:
|
||||||
|
# This might only raise IntegrityError if the email address is used
|
||||||
|
# by someone else
|
||||||
|
raise EmailAddressTakenError()
|
||||||
|
return u
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Second, check if we find the user based on their email address and this backend
|
||||||
|
try:
|
||||||
|
u = self.get(
|
||||||
|
auth_backend=backend,
|
||||||
|
auth_backend_identifier__isnull=True,
|
||||||
|
email=email,
|
||||||
|
)
|
||||||
|
u.auth_backend_identifier = identifier
|
||||||
|
for k, v in set_always.items():
|
||||||
|
setattr(u, k, v)
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
u.save(update_fields=['auth_backend_identifier'] + list(set_always.keys()))
|
||||||
|
return u
|
||||||
|
except IntegrityError:
|
||||||
|
# This might only raise IntegrityError if this code is being executed twice
|
||||||
|
# and runs into a race condition, this mechanism is taken from Django's
|
||||||
|
# get_or_create
|
||||||
|
try:
|
||||||
|
return self.get(
|
||||||
|
auth_backend=backend,
|
||||||
|
auth_backend_identifier=identifier,
|
||||||
|
)
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Third, create a new user
|
||||||
|
u = User(
|
||||||
|
auth_backend=backend,
|
||||||
|
auth_backend_identifier=identifier,
|
||||||
|
**set_on_creation,
|
||||||
|
**set_always,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
u.save(force_insert=True)
|
||||||
|
return u
|
||||||
|
except IntegrityError:
|
||||||
|
# This might either be a race condition or the email address is taken
|
||||||
|
# by a different backend
|
||||||
|
try:
|
||||||
|
return self.get(
|
||||||
|
auth_backend=backend,
|
||||||
|
auth_backend_identifier=identifier,
|
||||||
|
)
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
raise EmailAddressTakenError()
|
||||||
|
|
||||||
|
|
||||||
def generate_notifications_token():
|
def generate_notifications_token():
|
||||||
return get_random_string(length=32)
|
return get_random_string(length=32)
|
||||||
@@ -117,6 +231,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
:type needs_password_change: bool
|
:type needs_password_change: bool
|
||||||
:param timezone: The user's preferred timezone.
|
:param timezone: The user's preferred timezone.
|
||||||
:type timezone: str
|
:type timezone: str
|
||||||
|
:param auth_backend: The identifier of the authentication backend plugin responsible for managing this user.
|
||||||
|
:type auth_backend: str
|
||||||
|
:param auth_backend_identifier: The native identifier of the user provided by a non-native authentication backend.
|
||||||
|
:type auth_backend_identifier: str
|
||||||
"""
|
"""
|
||||||
|
|
||||||
USERNAME_FIELD = 'email'
|
USERNAME_FIELD = 'email'
|
||||||
@@ -152,6 +270,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
)
|
)
|
||||||
notifications_token = models.CharField(max_length=255, default=generate_notifications_token)
|
notifications_token = models.CharField(max_length=255, default=generate_notifications_token)
|
||||||
auth_backend = models.CharField(max_length=255, default='native')
|
auth_backend = models.CharField(max_length=255, default='native')
|
||||||
|
auth_backend_identifier = models.CharField(max_length=190, db_index=True, null=True, blank=True)
|
||||||
session_token = models.CharField(max_length=32, default=generate_session_token)
|
session_token = models.CharField(max_length=32, default=generate_session_token)
|
||||||
|
|
||||||
objects = UserManager()
|
objects = UserManager()
|
||||||
@@ -164,6 +283,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
verbose_name = _("User")
|
verbose_name = _("User")
|
||||||
verbose_name_plural = _("Users")
|
verbose_name_plural = _("Users")
|
||||||
ordering = ('email',)
|
ordering = ('email',)
|
||||||
|
unique_together = (('auth_backend', 'auth_backend_identifier'),)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
self.email = self.email.lower()
|
self.email = self.email.lower()
|
||||||
@@ -378,6 +498,23 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
| Q(id__in=self.teams.filter(**kwargs).values_list('limit_events__id', flat=True))
|
| Q(id__in=self.teams.filter(**kwargs).values_list('limit_events__id', flat=True))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@scopes_disabled()
|
||||||
|
def get_organizers_with_any_permission(self, request=None):
|
||||||
|
"""
|
||||||
|
Returns a queryset of organizers the user has any permissions to.
|
||||||
|
|
||||||
|
:param request: The current request (optional). Required to detect staff sessions properly.
|
||||||
|
:return: Iterable of Organizers
|
||||||
|
"""
|
||||||
|
from .event import Organizer
|
||||||
|
|
||||||
|
if request and self.has_active_staff_session(request.session.session_key):
|
||||||
|
return Organizer.objects.all()
|
||||||
|
|
||||||
|
return Organizer.objects.filter(
|
||||||
|
id__in=self.teams.values_list('organizer', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
@scopes_disabled()
|
@scopes_disabled()
|
||||||
def get_organizers_with_permission(self, permission, request=None):
|
def get_organizers_with_permission(self, permission, request=None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ class CheckinList(LoggedModel):
|
|||||||
# * in pretix.helpers.jsonlogic_boolalg
|
# * in pretix.helpers.jsonlogic_boolalg
|
||||||
# * in checkinrules.js
|
# * in checkinrules.js
|
||||||
# * in libpretixsync
|
# * in libpretixsync
|
||||||
|
# * in pretixscan-ios (in the future)
|
||||||
top_level_operators = {
|
top_level_operators = {
|
||||||
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
|
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
|
||||||
}
|
}
|
||||||
@@ -195,7 +196,8 @@ class CheckinList(LoggedModel):
|
|||||||
'buildTime', 'objectList', 'lookup', 'var',
|
'buildTime', 'objectList', 'lookup', 'var',
|
||||||
}
|
}
|
||||||
allowed_vars = {
|
allowed_vars = {
|
||||||
'product', 'variation', 'now', 'entries_number', 'entries_today', 'entries_days'
|
'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days',
|
||||||
|
'minutes_since_last_entry', 'minutes_since_first_entry',
|
||||||
}
|
}
|
||||||
if not rules or not isinstance(rules, dict):
|
if not rules or not isinstance(rules, dict):
|
||||||
return rules
|
return rules
|
||||||
@@ -221,7 +223,7 @@ class CheckinList(LoggedModel):
|
|||||||
return rules
|
return rules
|
||||||
|
|
||||||
if operator in ('or', 'and') and seen_nonbool:
|
if operator in ('or', 'and') and seen_nonbool:
|
||||||
raise ValidationError(f'You cannot use OR/AND logic on a level below a comparison operator.')
|
raise ValidationError('You cannot use OR/AND logic on a level below a comparison operator.')
|
||||||
|
|
||||||
for v in values:
|
for v in values:
|
||||||
cls.validate_rules(v, seen_nonbool=seen_nonbool or operator not in ('or', 'and'), depth=depth + 1)
|
cls.validate_rules(v, seen_nonbool=seen_nonbool or operator not in ('or', 'and'), depth=depth + 1)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from django.db.models import F, Q
|
|||||||
from django.utils.crypto import get_random_string, salted_hmac
|
from django.utils.crypto import get_random_string, salted_hmac
|
||||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||||
from django_scopes import ScopedManager, scopes_disabled
|
from django_scopes import ScopedManager, scopes_disabled
|
||||||
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
|
|
||||||
from pretix.base.banlist import banned
|
from pretix.base.banlist import banned
|
||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
@@ -45,6 +46,7 @@ class Customer(LoggedModel):
|
|||||||
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
||||||
identifier = models.CharField(max_length=190, db_index=True, unique=True)
|
identifier = models.CharField(max_length=190, db_index=True, unique=True)
|
||||||
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
|
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
|
||||||
|
phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number'))
|
||||||
password = models.CharField(verbose_name=_('Password'), max_length=128)
|
password = models.CharField(verbose_name=_('Password'), max_length=128)
|
||||||
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||||
name_parts = models.JSONField(default=dict)
|
name_parts = models.JSONField(default=dict)
|
||||||
@@ -87,6 +89,7 @@ class Customer(LoggedModel):
|
|||||||
self.name_parts = {}
|
self.name_parts = {}
|
||||||
self.name_cached = ''
|
self.name_cached = ''
|
||||||
self.email = None
|
self.email = None
|
||||||
|
self.phone = None
|
||||||
self.save()
|
self.save()
|
||||||
self.all_logentries().update(data={}, shredded=True)
|
self.all_logentries().update(data={}, shredded=True)
|
||||||
self.orders.all().update(customer=None)
|
self.orders.all().update(customer=None)
|
||||||
@@ -169,6 +172,7 @@ class Customer(LoggedModel):
|
|||||||
return salted_hmac(key_salt, payload).hexdigest()
|
return salted_hmac(key_salt, payload).hexdigest()
|
||||||
|
|
||||||
def get_email_context(self):
|
def get_email_context(self):
|
||||||
|
from pretix.base.email import get_name_parts_localized
|
||||||
ctx = {
|
ctx = {
|
||||||
'name': self.name,
|
'name': self.name,
|
||||||
'organizer': self.organizer.name,
|
'organizer': self.organizer.name,
|
||||||
@@ -177,7 +181,13 @@ class Customer(LoggedModel):
|
|||||||
for f, l, w in name_scheme['fields']:
|
for f, l, w in name_scheme['fields']:
|
||||||
if f == 'full_name':
|
if f == 'full_name':
|
||||||
continue
|
continue
|
||||||
ctx['name_%s' % f] = self.name_parts.get(f, '')
|
ctx['name_%s' % f] = get_name_parts_localized(self.name_parts, f)
|
||||||
|
|
||||||
|
if "concatenation_for_salutation" in name_scheme:
|
||||||
|
ctx['name_for_salutation'] = name_scheme["concatenation_for_salutation"](self.name_parts)
|
||||||
|
else:
|
||||||
|
ctx['name_for_salutation'] = name_scheme["concatenation"](self.name_parts)
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -156,6 +156,9 @@ class Device(LoggedModel):
|
|||||||
null=True,
|
null=True,
|
||||||
blank=False
|
blank=False
|
||||||
)
|
)
|
||||||
|
info = models.JSONField(
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
objects = ScopedManager(organizer='organizer')
|
objects = ScopedManager(organizer='organizer')
|
||||||
|
|
||||||
|
|||||||
@@ -665,21 +665,22 @@ class Event(EventMixin, LoggedModel):
|
|||||||
|
|
||||||
return locking.LockManager(self)
|
return locking.LockManager(self)
|
||||||
|
|
||||||
def get_mail_backend(self, timeout=None, force_custom=False):
|
def get_mail_backend(self, timeout=None):
|
||||||
"""
|
"""
|
||||||
Returns an email server connection, either by using the system-wide connection
|
Returns an email server connection, either by using the system-wide connection
|
||||||
or by returning a custom one based on the event's settings.
|
or by returning a custom one based on the event's settings.
|
||||||
"""
|
"""
|
||||||
from pretix.base.email import CustomSMTPBackend
|
|
||||||
|
|
||||||
if self.settings.smtp_use_custom or force_custom:
|
if self.settings.smtp_use_custom:
|
||||||
return CustomSMTPBackend(host=self.settings.smtp_host,
|
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||||
port=self.settings.smtp_port,
|
host=self.settings.smtp_host,
|
||||||
username=self.settings.smtp_username,
|
port=self.settings.smtp_port,
|
||||||
password=self.settings.smtp_password,
|
username=self.settings.smtp_username,
|
||||||
use_tls=self.settings.smtp_use_tls,
|
password=self.settings.smtp_password,
|
||||||
use_ssl=self.settings.smtp_use_ssl,
|
use_tls=self.settings.smtp_use_tls,
|
||||||
fail_silently=False, timeout=timeout)
|
use_ssl=self.settings.smtp_use_ssl,
|
||||||
|
fail_silently=False,
|
||||||
|
timeout=timeout)
|
||||||
else:
|
else:
|
||||||
return get_connection(fail_silently=False)
|
return get_connection(fail_silently=False)
|
||||||
|
|
||||||
@@ -1178,21 +1179,21 @@ class Event(EventMixin, LoggedModel):
|
|||||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||||
}
|
}
|
||||||
|
|
||||||
def set_active_plugins(self, modules, allow_restricted=False):
|
def set_active_plugins(self, modules, allow_restricted=frozenset()):
|
||||||
plugins_active = self.get_plugins()
|
plugins_active = self.get_plugins()
|
||||||
plugins_available = self.get_available_plugins()
|
plugins_available = self.get_available_plugins()
|
||||||
|
|
||||||
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
|
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
|
||||||
|
|
||||||
for module in enable:
|
for module in enable:
|
||||||
if getattr(plugins_available[module].app, 'restricted', False) and not allow_restricted:
|
if getattr(plugins_available[module].app, 'restricted', False) and module not in allow_restricted:
|
||||||
modules.remove(module)
|
modules.remove(module)
|
||||||
elif hasattr(plugins_available[module].app, 'installed'):
|
elif hasattr(plugins_available[module].app, 'installed'):
|
||||||
getattr(plugins_available[module].app, 'installed')(self)
|
getattr(plugins_available[module].app, 'installed')(self)
|
||||||
|
|
||||||
self.plugins = ",".join(modules)
|
self.plugins = ",".join(modules)
|
||||||
|
|
||||||
def enable_plugin(self, module, allow_restricted=False):
|
def enable_plugin(self, module, allow_restricted=frozenset()):
|
||||||
plugins_active = self.get_plugins()
|
plugins_active = self.get_plugins()
|
||||||
from pretix.presale.style import regenerate_css
|
from pretix.presale.style import regenerate_css
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ class MultiStringField(TextField):
|
|||||||
if isinstance(value, (list, tuple)):
|
if isinstance(value, (list, tuple)):
|
||||||
return DELIMITER + DELIMITER.join(value) + DELIMITER
|
return DELIMITER + DELIMITER.join(value) + DELIMITER
|
||||||
elif value is None:
|
elif value is None:
|
||||||
return ""
|
if self.null:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
raise TypeError("Invalid data type passed.")
|
raise TypeError("Invalid data type passed.")
|
||||||
|
|
||||||
def get_prep_lookup(self, lookup_type, value): # NOQA
|
def get_prep_lookup(self, lookup_type, value): # NOQA
|
||||||
@@ -78,6 +81,8 @@ class MultiStringField(TextField):
|
|||||||
return MultiStringContains
|
return MultiStringContains
|
||||||
elif lookup_name == 'icontains':
|
elif lookup_name == 'icontains':
|
||||||
return MultiStringIContains
|
return MultiStringIContains
|
||||||
|
elif lookup_name == 'isnull':
|
||||||
|
return builtin_lookups.IsNull
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"Lookup '{}' doesn't work with MultiStringField".format(lookup_name),
|
"Lookup '{}' doesn't work with MultiStringField".format(lookup_name),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import dateutil.parser
|
|||||||
import pytz
|
import pytz
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import RegexValidator
|
from django.core.validators import MinValueValidator, RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import formats
|
from django.utils import formats
|
||||||
@@ -479,12 +479,14 @@ class Item(LoggedModel):
|
|||||||
min_per_order = models.IntegerField(
|
min_per_order = models.IntegerField(
|
||||||
verbose_name=_('Minimum amount per order'),
|
verbose_name=_('Minimum amount per order'),
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
|
validators=[MinValueValidator(0)],
|
||||||
help_text=_('This product can only be bought if it is added to the cart at least this many times. If you keep '
|
help_text=_('This product can only be bought if it is added to the cart at least this many times. If you keep '
|
||||||
'the field empty or set it to 0, there is no special limit for this product.')
|
'the field empty or set it to 0, there is no special limit for this product.')
|
||||||
)
|
)
|
||||||
max_per_order = models.IntegerField(
|
max_per_order = models.IntegerField(
|
||||||
verbose_name=_('Maximum amount per order'),
|
verbose_name=_('Maximum amount per order'),
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
|
validators=[MinValueValidator(0)],
|
||||||
help_text=_('This product can only be bought at most this many times within one order. If you keep the field '
|
help_text=_('This product can only be bought at most this many times within one order. If you keep the field '
|
||||||
'empty or set it to 0, there is no special limit for this product. The limit for the maximum '
|
'empty or set it to 0, there is no special limit for this product. The limit for the maximum '
|
||||||
'number of items in the whole order applies regardless.')
|
'number of items in the whole order applies regardless.')
|
||||||
@@ -764,6 +766,9 @@ class ItemVariation(models.Model):
|
|||||||
:type default_price: decimal.Decimal
|
:type default_price: decimal.Decimal
|
||||||
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
|
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
|
||||||
:type original_price: decimal.Decimal
|
:type original_price: decimal.Decimal
|
||||||
|
:param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after
|
||||||
|
approval by an administrator
|
||||||
|
:type require_approval: bool
|
||||||
"""
|
"""
|
||||||
item = models.ForeignKey(
|
item = models.ForeignKey(
|
||||||
Item,
|
Item,
|
||||||
@@ -799,6 +804,13 @@ class ItemVariation(models.Model):
|
|||||||
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
|
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
|
||||||
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
|
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
|
||||||
)
|
)
|
||||||
|
require_approval = models.BooleanField(
|
||||||
|
verbose_name=_('Require approval'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('If this variation is part of an order, the order will be put into an "approval" state and '
|
||||||
|
'will need to be confirmed by you before it can be paid and completed. You can use this e.g. for '
|
||||||
|
'discounted tickets that are only available to specific groups.'),
|
||||||
|
)
|
||||||
require_membership = models.BooleanField(
|
require_membership = models.BooleanField(
|
||||||
verbose_name=_('Require a valid membership'),
|
verbose_name=_('Require a valid membership'),
|
||||||
default=False,
|
default=False,
|
||||||
@@ -832,7 +844,7 @@ class ItemVariation(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
hide_without_voucher = models.BooleanField(
|
hide_without_voucher = models.BooleanField(
|
||||||
verbose_name=_('This variation will only be shown if a voucher matching the product is redeemed.'),
|
verbose_name=_('Show only if a matching voucher is redeemed.'),
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
|
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
|
||||||
'that unlocks this variation.')
|
'that unlocks this variation.')
|
||||||
@@ -1687,7 +1699,7 @@ class Quota(LoggedModel):
|
|||||||
if event != item.event:
|
if event != item.event:
|
||||||
raise ValidationError(_('One or more items do not belong to this event.'))
|
raise ValidationError(_('One or more items do not belong to this event.'))
|
||||||
if item.has_variations:
|
if item.has_variations:
|
||||||
if not any(var.item == item for var in variations):
|
if not variations or not any(var.item == item for var in variations):
|
||||||
raise ValidationError(_('One or more items has variations but none of these are in the variations list.'))
|
raise ValidationError(_('One or more items has variations but none of these are in the variations list.'))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -638,12 +638,13 @@ class Order(LockModel, LoggedModel):
|
|||||||
return False
|
return False
|
||||||
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||||
return False
|
return False
|
||||||
if self.status == Order.STATUS_PENDING:
|
|
||||||
return self.event.settings.cancel_allow_user
|
if self.status == Order.STATUS_PAID or self.payment_refund_sum > Decimal('0.00'):
|
||||||
elif self.status == Order.STATUS_PAID:
|
|
||||||
if self.total == Decimal('0.00'):
|
if self.total == Decimal('0.00'):
|
||||||
return self.event.settings.cancel_allow_user
|
return self.event.settings.cancel_allow_user
|
||||||
return self.event.settings.cancel_allow_user_paid
|
return self.event.settings.cancel_allow_user_paid
|
||||||
|
elif self.status == Order.STATUS_PENDING:
|
||||||
|
return self.event.settings.cancel_allow_user
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def propose_auto_refunds(self, amount: Decimal, payments: list=None):
|
def propose_auto_refunds(self, amount: Decimal, payments: list=None):
|
||||||
@@ -950,7 +951,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||||
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
|
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
|
||||||
attach_ical=False):
|
attach_ical=False, attach_other_files: list=None):
|
||||||
"""
|
"""
|
||||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||||
|
|
||||||
@@ -976,7 +977,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
SendMailException, TolerantDict, mail, render_mail,
|
SendMailException, TolerantDict, mail, render_mail,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not self.email:
|
if not self.email and not (position and position.attendee_email):
|
||||||
return
|
return
|
||||||
|
|
||||||
for k, v in self.event.meta_data.items():
|
for k, v in self.event.meta_data.items():
|
||||||
@@ -994,7 +995,8 @@ class Order(LockModel, LoggedModel):
|
|||||||
recipient, subject, template, context,
|
recipient, subject, template, context,
|
||||||
self.event, self.locale, self, headers=headers, sender=sender,
|
self.event, self.locale, self, headers=headers, sender=sender,
|
||||||
invoices=invoices, attach_tickets=attach_tickets,
|
invoices=invoices, attach_tickets=attach_tickets,
|
||||||
position=position, auto_email=auto_email, attach_ical=attach_ical
|
position=position, auto_email=auto_email, attach_ical=attach_ical,
|
||||||
|
attach_other_files=attach_other_files,
|
||||||
)
|
)
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
raise
|
raise
|
||||||
@@ -1329,6 +1331,10 @@ class AbstractPosition(models.Model):
|
|||||||
else:
|
else:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def item_and_variation(self):
|
||||||
|
return self.item, self.variation
|
||||||
|
|
||||||
@meta_info_data.setter
|
@meta_info_data.setter
|
||||||
def meta_info_data(self, d):
|
def meta_info_data(self, d):
|
||||||
self.meta_info = json.dumps(d)
|
self.meta_info = json.dumps(d)
|
||||||
@@ -1441,6 +1447,15 @@ class AbstractPosition(models.Model):
|
|||||||
lines = [r.strip() for r in lines if r]
|
lines = [r.strip() for r in lines if r]
|
||||||
return '\n'.join(lines).strip()
|
return '\n'.join(lines).strip()
|
||||||
|
|
||||||
|
def requires_approval(self, invoice_address=None):
|
||||||
|
if self.item.require_approval:
|
||||||
|
return True
|
||||||
|
if self.variation and self.variation.require_approval:
|
||||||
|
return True
|
||||||
|
if self.item.tax_rule and self.item.tax_rule._require_approval(invoice_address):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class OrderPayment(models.Model):
|
class OrderPayment(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -1714,10 +1729,10 @@ class OrderPayment(models.Model):
|
|||||||
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
|
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
|
||||||
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
|
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
|
||||||
try:
|
try:
|
||||||
self.order.send_mail(
|
position.send_mail(
|
||||||
email_subject, email_template, email_context,
|
email_subject, email_template, email_context,
|
||||||
'pretix.event.order.email.order_paid', user,
|
'pretix.event.order.email.order_paid', user,
|
||||||
invoices=[], position=position,
|
invoices=[],
|
||||||
attach_tickets=True,
|
attach_tickets=True,
|
||||||
attach_ical=self.order.event.settings.mail_attach_ical
|
attach_ical=self.order.event.settings.mail_attach_ical
|
||||||
)
|
)
|
||||||
@@ -2316,7 +2331,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||||
auth=None, attach_tickets=False, attach_ical=False):
|
auth=None, attach_tickets=False, attach_ical=False, attach_other_files: list=None):
|
||||||
"""
|
"""
|
||||||
Sends an email to the attendee. Basically, this method does two things:
|
Sends an email to the attendee. Basically, this method does two things:
|
||||||
|
|
||||||
@@ -2357,6 +2372,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
invoices=invoices,
|
invoices=invoices,
|
||||||
attach_tickets=attach_tickets,
|
attach_tickets=attach_tickets,
|
||||||
attach_ical=attach_ical,
|
attach_ical=attach_ical,
|
||||||
|
attach_other_files=attach_other_files,
|
||||||
)
|
)
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import string
|
|||||||
from datetime import date, datetime, time
|
from datetime import date, datetime, time
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
from django.conf import settings
|
||||||
from django.core.mail import get_connection
|
from django.core.mail import get_connection
|
||||||
from django.core.validators import MinLengthValidator, RegexValidator
|
from django.core.validators import MinLengthValidator, RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@@ -97,10 +98,21 @@ class Organizer(LoggedModel):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
is_new = not self.pk
|
||||||
obj = super().save(*args, **kwargs)
|
obj = super().save(*args, **kwargs)
|
||||||
self.get_cache().clear()
|
if is_new:
|
||||||
|
self.set_defaults()
|
||||||
|
else:
|
||||||
|
self.get_cache().clear()
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
def set_defaults(self):
|
||||||
|
"""
|
||||||
|
This will be called after organizer creation.
|
||||||
|
This way, we can use this to introduce new default settings to pretix that do not affect existing organizers.
|
||||||
|
"""
|
||||||
|
self.settings.cookie_consent = True
|
||||||
|
|
||||||
def get_cache(self):
|
def get_cache(self):
|
||||||
"""
|
"""
|
||||||
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
|
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
|
||||||
@@ -179,21 +191,20 @@ class Organizer(LoggedModel):
|
|||||||
e.delete()
|
e.delete()
|
||||||
self.teams.all().delete()
|
self.teams.all().delete()
|
||||||
|
|
||||||
def get_mail_backend(self, timeout=None, force_custom=False):
|
def get_mail_backend(self, timeout=None):
|
||||||
"""
|
"""
|
||||||
Returns an email server connection, either by using the system-wide connection
|
Returns an email server connection, either by using the system-wide connection
|
||||||
or by returning a custom one based on the organizer's settings.
|
or by returning a custom one based on the organizer's settings.
|
||||||
"""
|
"""
|
||||||
from pretix.base.email import CustomSMTPBackend
|
if self.settings.smtp_use_custom:
|
||||||
|
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||||
if self.settings.smtp_use_custom or force_custom:
|
host=self.settings.smtp_host,
|
||||||
return CustomSMTPBackend(host=self.settings.smtp_host,
|
port=self.settings.smtp_port,
|
||||||
port=self.settings.smtp_port,
|
username=self.settings.smtp_username,
|
||||||
username=self.settings.smtp_username,
|
password=self.settings.smtp_password,
|
||||||
password=self.settings.smtp_password,
|
use_tls=self.settings.smtp_use_tls,
|
||||||
use_tls=self.settings.smtp_use_tls,
|
use_ssl=self.settings.smtp_use_ssl,
|
||||||
use_ssl=self.settings.smtp_use_ssl,
|
fail_silently=False, timeout=timeout)
|
||||||
fail_silently=False, timeout=timeout)
|
|
||||||
else:
|
else:
|
||||||
return get_connection(fail_silently=False)
|
return get_connection(fail_silently=False)
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import jsonschema
|
|||||||
from django.contrib.staticfiles import finders
|
from django.contrib.staticfiles import finders
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Exists, F, OuterRef, Q, Value
|
from django.db.models import Exists, F, OuterRef, Q, Subquery, Value
|
||||||
from django.db.models.functions import Power
|
from django.db.models.functions import Power
|
||||||
from django.utils.deconstruct import deconstructible
|
from django.utils.deconstruct import deconstructible
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
@@ -281,10 +281,26 @@ class Seat(models.Model):
|
|||||||
q = Q(has_order=True) | Q(has_voucher=True)
|
q = Q(has_order=True) | Q(has_voucher=True)
|
||||||
if ignore_cart is not True:
|
if ignore_cart is not True:
|
||||||
q |= Q(has_cart=True)
|
q |= Q(has_cart=True)
|
||||||
|
|
||||||
|
# The following looks like it makes no sense. Why wouldn't we just use ``Value(self.x)``, we already now
|
||||||
|
# the value? The reason is that x and y are floating point values generated from our JSON files. As it turns
|
||||||
|
# out, PostgreSQL MIGHT store floating point values with a different precision based on the underlying system
|
||||||
|
# architecture. So if we generate e.g. 670.247128887222289 from the JSON file and store it to the database,
|
||||||
|
# PostgreSQL will store it as 670.247128887222289 internally. However if we query it again, we only get
|
||||||
|
# 670.247128887222 back. But if we do calculations with a field in PostgreSQL itself, it uses the full
|
||||||
|
# precision for the calculation.
|
||||||
|
# We don't actually care about the results with this precision, but we care that the results from this
|
||||||
|
# function are exactly the same as from event.free_seats(), so we do this subquery trick to deal with
|
||||||
|
# PostgreSQL's internal values in both cases.
|
||||||
|
# In the long run, we probably just want to round the numbers on insert...
|
||||||
|
# See also https://www.postgresql.org/docs/11/runtime-config-client.html#GUC-EXTRA-FLOAT-DIGITS
|
||||||
|
self_x = Subquery(Seat.objects.filter(pk=self.pk).values('x'))
|
||||||
|
self_y = Subquery(Seat.objects.filter(pk=self.pk).values('y'))
|
||||||
|
|
||||||
qs_closeby_taken = qs_annotated.annotate(
|
qs_closeby_taken = qs_annotated.annotate(
|
||||||
distance=(
|
distance=(
|
||||||
Power(F('x') - Value(self.x), Value(2), output_field=models.FloatField()) +
|
Power(F('x') - self_x, Value(2), output_field=models.FloatField()) +
|
||||||
Power(F('y') - Value(self.y), Value(2), output_field=models.FloatField())
|
Power(F('y') - self_y, Value(2), output_field=models.FloatField())
|
||||||
)
|
)
|
||||||
).exclude(pk=self.pk).filter(
|
).exclude(pk=self.pk).filter(
|
||||||
q,
|
q,
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ class TaxedPrice:
|
|||||||
name=self.name,
|
name=self.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (
|
||||||
|
self.gross == other.gross and
|
||||||
|
self.net == other.net and
|
||||||
|
self.tax == other.tax and
|
||||||
|
self.rate == other.rate and
|
||||||
|
self.name == other.name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
TAXED_ZERO = TaxedPrice(
|
TAXED_ZERO = TaxedPrice(
|
||||||
gross=Decimal('0.00'),
|
gross=Decimal('0.00'),
|
||||||
@@ -127,8 +136,13 @@ def cc_to_vat_prefix(country_code):
|
|||||||
|
|
||||||
class TaxRule(LoggedModel):
|
class TaxRule(LoggedModel):
|
||||||
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
|
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
|
||||||
|
internal_name = models.CharField(
|
||||||
|
verbose_name=_('Internal name'),
|
||||||
|
max_length=190,
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
name = I18nCharField(
|
name = I18nCharField(
|
||||||
verbose_name=_('Name'),
|
verbose_name=_('Official name'),
|
||||||
help_text=_('Should be short, e.g. "VAT"'),
|
help_text=_('Should be short, e.g. "VAT"'),
|
||||||
max_length=190,
|
max_length=190,
|
||||||
)
|
)
|
||||||
@@ -141,6 +155,10 @@ class TaxRule(LoggedModel):
|
|||||||
verbose_name=_("The configured product prices include the tax amount"),
|
verbose_name=_("The configured product prices include the tax amount"),
|
||||||
default=True,
|
default=True,
|
||||||
)
|
)
|
||||||
|
keep_gross_if_rate_changes = models.BooleanField(
|
||||||
|
verbose_name=_("Keep gross amount constant if the tax rate changes based on the invoice address"),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
eu_reverse_charge = models.BooleanField(
|
eu_reverse_charge = models.BooleanField(
|
||||||
verbose_name=_("Use EU reverse charge taxation rules"),
|
verbose_name=_("Use EU reverse charge taxation rules"),
|
||||||
default=False,
|
default=False,
|
||||||
@@ -198,6 +216,8 @@ class TaxRule(LoggedModel):
|
|||||||
s = _('plus {rate}% {name}').format(rate=self.rate, name=self.name)
|
s = _('plus {rate}% {name}').format(rate=self.rate, name=self.name)
|
||||||
if self.eu_reverse_charge:
|
if self.eu_reverse_charge:
|
||||||
s += ' ({})'.format(_('reverse charge enabled'))
|
s += ' ({})'.format(_('reverse charge enabled'))
|
||||||
|
if self.internal_name:
|
||||||
|
return f'{self.internal_name} ({s})'
|
||||||
return str(s)
|
return str(s)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -211,7 +231,7 @@ class TaxRule(LoggedModel):
|
|||||||
rule = self.get_matching_rule(invoice_address)
|
rule = self.get_matching_rule(invoice_address)
|
||||||
if rule.get('action', 'vat') == 'block':
|
if rule.get('action', 'vat') == 'block':
|
||||||
raise self.SaleNotAllowed()
|
raise self.SaleNotAllowed()
|
||||||
if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
|
if rule.get('action', 'vat') in ('vat', 'require_approval') and rule.get('rate') is not None:
|
||||||
return Decimal(rule.get('rate'))
|
return Decimal(rule.get('rate'))
|
||||||
return Decimal(self.rate)
|
return Decimal(self.rate)
|
||||||
|
|
||||||
@@ -228,13 +248,19 @@ class TaxRule(LoggedModel):
|
|||||||
rate = override_tax_rate
|
rate = override_tax_rate
|
||||||
elif invoice_address:
|
elif invoice_address:
|
||||||
adjust_rate = self.tax_rate_for(invoice_address)
|
adjust_rate = self.tax_rate_for(invoice_address)
|
||||||
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price) and base_price_is == 'gross':
|
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price or self.keep_gross_if_rate_changes) and base_price_is == 'gross':
|
||||||
rate = adjust_rate
|
rate = adjust_rate
|
||||||
elif adjust_rate != rate:
|
elif adjust_rate != rate:
|
||||||
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
if self.keep_gross_if_rate_changes:
|
||||||
base_price = normal_price.net
|
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
||||||
base_price_is = 'net'
|
base_price = normal_price.gross
|
||||||
subtract_from_gross = Decimal('0.00')
|
base_price_is = 'gross'
|
||||||
|
subtract_from_gross = Decimal('0.00')
|
||||||
|
else:
|
||||||
|
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
||||||
|
base_price = normal_price.net
|
||||||
|
base_price_is = 'net'
|
||||||
|
subtract_from_gross = Decimal('0.00')
|
||||||
rate = adjust_rate
|
rate = adjust_rate
|
||||||
|
|
||||||
if rate == Decimal('0.00'):
|
if rate == Decimal('0.00'):
|
||||||
@@ -337,12 +363,19 @@ class TaxRule(LoggedModel):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _require_approval(self, invoice_address):
|
||||||
|
if self._custom_rules:
|
||||||
|
rule = self.get_matching_rule(invoice_address)
|
||||||
|
if rule.get('action', 'vat') == 'require_approval':
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _tax_applicable(self, invoice_address):
|
def _tax_applicable(self, invoice_address):
|
||||||
if self._custom_rules:
|
if self._custom_rules:
|
||||||
rule = self.get_matching_rule(invoice_address)
|
rule = self.get_matching_rule(invoice_address)
|
||||||
if rule.get('action', 'vat') == 'block':
|
if rule.get('action', 'vat') == 'block':
|
||||||
raise self.SaleNotAllowed()
|
raise self.SaleNotAllowed()
|
||||||
return rule.get('action', 'vat') == 'vat'
|
return rule.get('action', 'vat') in ('vat', 'require_approval')
|
||||||
|
|
||||||
if not self.eu_reverse_charge:
|
if not self.eu_reverse_charge:
|
||||||
# No reverse charge rules? Always apply VAT!
|
# No reverse charge rules? Always apply VAT!
|
||||||
|
|||||||
@@ -191,6 +191,15 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
return self.verbose_name
|
return self.verbose_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def confirm_button_name(self) -> str:
|
||||||
|
"""
|
||||||
|
A label for the "confirm" button on the last page before a payment is started. This
|
||||||
|
is **not** used in the regular checkout flow, but only if the payment method is selected
|
||||||
|
for an existing order later on.
|
||||||
|
"""
|
||||||
|
return _("Pay now")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -946,6 +955,8 @@ class BoxOfficeProvider(BasePaymentProvider):
|
|||||||
return {
|
return {
|
||||||
"pos_id": payment.info_data.get('pos_id', None),
|
"pos_id": payment.info_data.get('pos_id', None),
|
||||||
"receipt_id": payment.info_data.get('receipt_id', None),
|
"receipt_id": payment.info_data.get('receipt_id', None),
|
||||||
|
"payment_type": payment.info_data.get('payment_type', None),
|
||||||
|
"payment_data": payment.info_data.get('payment_data', {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def payment_control_render(self, request, payment) -> str:
|
def payment_control_render(self, request, payment) -> str:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import hashlib
|
|||||||
import itertools
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
@@ -48,12 +49,14 @@ from arabic_reshaper import ArabicReshaper
|
|||||||
from bidi.algorithm import get_display
|
from bidi.algorithm import get_display
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.staticfiles import finders
|
from django.contrib.staticfiles import finders
|
||||||
|
from django.db.models import Max, Min
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
from django.utils.functional import SimpleLazyObject
|
from django.utils.functional import SimpleLazyObject
|
||||||
from django.utils.html import conditional_escape
|
from django.utils.html import conditional_escape
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from i18nfield.strings import LazyI18nString
|
||||||
from PyPDF2 import PdfFileReader
|
from PyPDF2 import PdfFileReader
|
||||||
from pytz import timezone
|
from pytz import timezone
|
||||||
from reportlab.graphics import renderPDF
|
from reportlab.graphics import renderPDF
|
||||||
@@ -202,6 +205,11 @@ DEFAULT_VARIABLES = OrderedDict((
|
|||||||
"editor_sample": 'foo@bar.com',
|
"editor_sample": 'foo@bar.com',
|
||||||
"evaluate": lambda op, order, ev: op.attendee_email or (op.addon_to.attendee_email if op.addon_to else '')
|
"evaluate": lambda op, order, ev: op.attendee_email or (op.addon_to.attendee_email if op.addon_to else '')
|
||||||
}),
|
}),
|
||||||
|
("pseudonymization_id", {
|
||||||
|
"label": _("Pseudonymization ID (lead scanning)"),
|
||||||
|
"editor_sample": "GG89JUJDTA",
|
||||||
|
"evaluate": lambda orderposition, order, event: orderposition.pseudonymization_id,
|
||||||
|
}),
|
||||||
("event_name", {
|
("event_name", {
|
||||||
"label": _("Event name"),
|
"label": _("Event name"),
|
||||||
"editor_sample": _("Sample event name"),
|
"editor_sample": _("Sample event name"),
|
||||||
@@ -387,30 +395,41 @@ DEFAULT_VARIABLES = OrderedDict((
|
|||||||
("seat", {
|
("seat", {
|
||||||
"label": _("Seat: Full name"),
|
"label": _("Seat: Full name"),
|
||||||
"editor_sample": _("Ground floor, Row 3, Seat 4"),
|
"editor_sample": _("Ground floor, Row 3, Seat 4"),
|
||||||
"evaluate": lambda op, order, ev: str(op.seat if op.seat else
|
"evaluate": lambda op, order, ev: str(get_seat(op) if get_seat(op) else
|
||||||
_('General admission') if ev.seating_plan_id is not None else "")
|
_('General admission') if ev.seating_plan_id is not None else "")
|
||||||
}),
|
}),
|
||||||
("seat_zone", {
|
("seat_zone", {
|
||||||
"label": _("Seat: zone"),
|
"label": _("Seat: zone"),
|
||||||
"editor_sample": _("Ground floor"),
|
"editor_sample": _("Ground floor"),
|
||||||
"evaluate": lambda op, order, ev: str(op.seat.zone_name if op.seat else
|
"evaluate": lambda op, order, ev: str(get_seat(op).zone_name if get_seat(op) else
|
||||||
_('General admission') if ev.seating_plan_id is not None else "")
|
_('General admission') if ev.seating_plan_id is not None else "")
|
||||||
}),
|
}),
|
||||||
("seat_row", {
|
("seat_row", {
|
||||||
"label": _("Seat: row"),
|
"label": _("Seat: row"),
|
||||||
"editor_sample": "3",
|
"editor_sample": "3",
|
||||||
"evaluate": lambda op, order, ev: str(op.seat.row_name if op.seat else "")
|
"evaluate": lambda op, order, ev: str(get_seat(op).row_name if get_seat(op) else "")
|
||||||
}),
|
}),
|
||||||
("seat_number", {
|
("seat_number", {
|
||||||
"label": _("Seat: seat number"),
|
"label": _("Seat: seat number"),
|
||||||
"editor_sample": 4,
|
"editor_sample": 4,
|
||||||
"evaluate": lambda op, order, ev: str(op.seat.seat_number if op.seat else "")
|
"evaluate": lambda op, order, ev: str(get_seat(op).seat_number if get_seat(op) else "")
|
||||||
}),
|
}),
|
||||||
("first_scan", {
|
("first_scan", {
|
||||||
"label": _("Date and time of first scan"),
|
"label": _("Date and time of first scan"),
|
||||||
"editor_sample": _("2017-05-31 19:00"),
|
"editor_sample": _("2017-05-31 19:00"),
|
||||||
"evaluate": lambda op, order, ev: get_first_scan(op)
|
"evaluate": lambda op, order, ev: get_first_scan(op)
|
||||||
}),
|
}),
|
||||||
|
("giftcard_issuance_date", {
|
||||||
|
|
||||||
|
"label": _("Gift card: Issuance date"),
|
||||||
|
"editor_sample": _("2017-05-31"),
|
||||||
|
"evaluate": lambda op, order, ev: get_giftcard_issuance(op, ev)
|
||||||
|
}),
|
||||||
|
("giftcard_expiry_date", {
|
||||||
|
"label": _("Gift card: Expiration date"),
|
||||||
|
"editor_sample": _("2017-05-31"),
|
||||||
|
"evaluate": lambda op, order, ev: get_giftcard_expiry(op, ev)
|
||||||
|
}),
|
||||||
))
|
))
|
||||||
DEFAULT_IMAGES = OrderedDict([])
|
DEFAULT_IMAGES = OrderedDict([])
|
||||||
|
|
||||||
@@ -485,10 +504,17 @@ def variables_from_questions(sender, *args, **kwargs):
|
|||||||
for q in sender.questions.all():
|
for q in sender.questions.all():
|
||||||
if q.type == Question.TYPE_FILE:
|
if q.type == Question.TYPE_FILE:
|
||||||
continue
|
continue
|
||||||
|
d['question_{}'.format(q.identifier)] = {
|
||||||
|
'label': _('Question: {question}').format(question=q.question),
|
||||||
|
'editor_sample': _('<Answer: {question}>').format(question=q.question),
|
||||||
|
'evaluate': partial(get_answer, question_id=q.pk),
|
||||||
|
'migrate_from': 'question_{}'.format(q.pk)
|
||||||
|
}
|
||||||
d['question_{}'.format(q.pk)] = {
|
d['question_{}'.format(q.pk)] = {
|
||||||
'label': _('Question: {question}').format(question=q.question),
|
'label': _('Question: {question}').format(question=q.question),
|
||||||
'editor_sample': _('<Answer: {question}>').format(question=q.question),
|
'editor_sample': _('<Answer: {question}>').format(question=q.question),
|
||||||
'evaluate': partial(get_answer, question_id=q.pk)
|
'evaluate': partial(get_answer, question_id=q.pk),
|
||||||
|
'hidden': True,
|
||||||
}
|
}
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@@ -546,6 +572,24 @@ def get_variables(event):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def get_giftcard_expiry(op: OrderPosition, ev):
|
||||||
|
if not op.item.issue_giftcard:
|
||||||
|
return "" # performance optimization
|
||||||
|
m = op.issued_gift_cards.aggregate(m=Min('expires'))['m']
|
||||||
|
if not m:
|
||||||
|
return ""
|
||||||
|
return date_format(m.astimezone(ev.timezone), "SHORT_DATE_FORMAT")
|
||||||
|
|
||||||
|
|
||||||
|
def get_giftcard_issuance(op: OrderPosition, ev):
|
||||||
|
if not op.item.issue_giftcard:
|
||||||
|
return "" # performance optimization
|
||||||
|
m = op.issued_gift_cards.aggregate(m=Max('issuance'))['m']
|
||||||
|
if not m:
|
||||||
|
return ""
|
||||||
|
return date_format(m.astimezone(ev.timezone), "SHORT_DATE_FORMAT")
|
||||||
|
|
||||||
|
|
||||||
def get_first_scan(op: OrderPosition):
|
def get_first_scan(op: OrderPosition):
|
||||||
scans = list(op.checkins.all())
|
scans = list(op.checkins.all())
|
||||||
|
|
||||||
@@ -557,6 +601,14 @@ def get_first_scan(op: OrderPosition):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_seat(op: OrderPosition):
|
||||||
|
if op.seat_id:
|
||||||
|
return op.seat
|
||||||
|
if op.addon_to_id:
|
||||||
|
return op.addon_to.seat
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
reshaper = SimpleLazyObject(lambda: ArabicReshaper(configuration={
|
reshaper = SimpleLazyObject(lambda: ArabicReshaper(configuration={
|
||||||
'delete_harakat': True,
|
'delete_harakat': True,
|
||||||
'support_ligatures': False,
|
'support_ligatures': False,
|
||||||
@@ -616,12 +668,14 @@ class Renderer:
|
|||||||
preserveAspectRatio=True, anchor='n',
|
preserveAspectRatio=True, anchor='n',
|
||||||
mask='auto')
|
mask='auto')
|
||||||
|
|
||||||
def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, o: dict):
|
def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
||||||
content = o.get('content', 'secret')
|
content = o.get('content', 'secret')
|
||||||
if content == 'secret':
|
if content == 'secret':
|
||||||
|
# do not use get_text_content because it uses a shortened version of secret
|
||||||
|
# and does not deal with our default value here properly
|
||||||
content = op.secret
|
content = op.secret
|
||||||
elif content == 'pseudonymization_id':
|
else:
|
||||||
content = op.pseudonymization_id
|
content = self._get_text_content(op, order, o)
|
||||||
|
|
||||||
level = 'H'
|
level = 'H'
|
||||||
if len(content) > 32:
|
if len(content) > 32:
|
||||||
@@ -648,20 +702,51 @@ class Renderer:
|
|||||||
return self._get_text_content(op, order, o, True)
|
return self._get_text_content(op, order, o, True)
|
||||||
|
|
||||||
ev = self._get_ev(op, order)
|
ev = self._get_ev(op, order)
|
||||||
|
|
||||||
if not o['content']:
|
if not o['content']:
|
||||||
return '(error)'
|
return '(error)'
|
||||||
if o['content'] == 'other':
|
|
||||||
return o['text']
|
if o['content'] == 'other' or o['content'] == 'other_i18n':
|
||||||
|
if o['content'] == 'other_i18n':
|
||||||
|
text = str(LazyI18nString(o['text_i18n']))
|
||||||
|
else:
|
||||||
|
text = o['text']
|
||||||
|
|
||||||
|
def replace(x):
|
||||||
|
print(x.group(1))
|
||||||
|
if x.group(1).startswith('itemmeta:'):
|
||||||
|
return op.item.meta_data.get(x.group(1)[9:]) or ''
|
||||||
|
elif x.group(1).startswith('meta:'):
|
||||||
|
return ev.meta_data.get(x.group(1)[5:]) or ''
|
||||||
|
elif x.group(1) not in self.variables:
|
||||||
|
return x.group(0)
|
||||||
|
if x.group(1) == 'secret':
|
||||||
|
# Do not use shortened version
|
||||||
|
return op.secret
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.variables[x.group(1)]['evaluate'](op, order, ev)
|
||||||
|
except:
|
||||||
|
logger.exception('Failed to process variable.')
|
||||||
|
return '(error)'
|
||||||
|
|
||||||
|
# We do not use str.format like in emails so we (a) can evaluate lazily and (b) can re-implement this
|
||||||
|
# 1:1 on other platforms that render PDFs through our API (libpretixprint)
|
||||||
|
return re.sub(r'\{([a-zA-Z0-9:_]+)\}', replace, text)
|
||||||
|
|
||||||
elif o['content'].startswith('itemmeta:'):
|
elif o['content'].startswith('itemmeta:'):
|
||||||
return op.item.meta_data.get(o['content'][9:]) or ''
|
return op.item.meta_data.get(o['content'][9:]) or ''
|
||||||
|
|
||||||
elif o['content'].startswith('meta:'):
|
elif o['content'].startswith('meta:'):
|
||||||
return ev.meta_data.get(o['content'][5:]) or ''
|
return ev.meta_data.get(o['content'][5:]) or ''
|
||||||
|
|
||||||
elif o['content'] in self.variables:
|
elif o['content'] in self.variables:
|
||||||
try:
|
try:
|
||||||
return self.variables[o['content']]['evaluate'](op, order, ev)
|
return self.variables[o['content']]['evaluate'](op, order, ev)
|
||||||
except:
|
except:
|
||||||
logger.exception('Failed to process variable.')
|
logger.exception('Failed to process variable.')
|
||||||
return '(error)'
|
return '(error)'
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
def _draw_imagearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
def _draw_imagearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
||||||
@@ -754,20 +839,30 @@ class Renderer:
|
|||||||
p.drawOn(canvas, 0, -h - ad[1])
|
p.drawOn(canvas, 0, -h - ad[1])
|
||||||
canvas.restoreState()
|
canvas.restoreState()
|
||||||
|
|
||||||
def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition, show_page=True):
|
def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition, show_page=True, only_page=None):
|
||||||
for o in self.layout:
|
page_count = self.bg_pdf.getNumPages()
|
||||||
if o['type'] == "barcodearea":
|
|
||||||
self._draw_barcodearea(canvas, op, o)
|
if not only_page and not show_page:
|
||||||
elif o['type'] == "imagearea":
|
raise ValueError("only_page=None and show_page=False cannot be combined")
|
||||||
self._draw_imagearea(canvas, op, order, o)
|
|
||||||
elif o['type'] == "textarea":
|
for page in range(page_count):
|
||||||
self._draw_textarea(canvas, op, order, o)
|
if only_page and only_page != page + 1:
|
||||||
elif o['type'] == "poweredby":
|
continue
|
||||||
self._draw_poweredby(canvas, op, o)
|
for o in self.layout:
|
||||||
if self.bg_pdf:
|
if o.get('page', 1) != page + 1:
|
||||||
canvas.setPageSize((self.bg_pdf.getPage(0).mediaBox[2], self.bg_pdf.getPage(0).mediaBox[3]))
|
continue
|
||||||
if show_page:
|
if o['type'] == "barcodearea":
|
||||||
canvas.showPage()
|
self._draw_barcodearea(canvas, op, order, o)
|
||||||
|
elif o['type'] == "imagearea":
|
||||||
|
self._draw_imagearea(canvas, op, order, o)
|
||||||
|
elif o['type'] == "textarea":
|
||||||
|
self._draw_textarea(canvas, op, order, o)
|
||||||
|
elif o['type'] == "poweredby":
|
||||||
|
self._draw_poweredby(canvas, op, o)
|
||||||
|
if self.bg_pdf:
|
||||||
|
canvas.setPageSize((self.bg_pdf.getPage(page).mediaBox[2], self.bg_pdf.getPage(page).mediaBox[3]))
|
||||||
|
if show_page:
|
||||||
|
canvas.showPage()
|
||||||
|
|
||||||
def render_background(self, buffer, title=_('Ticket')):
|
def render_background(self, buffer, title=_('Ticket')):
|
||||||
if settings.PDFTK:
|
if settings.PDFTK:
|
||||||
@@ -780,7 +875,7 @@ class Renderer:
|
|||||||
subprocess.run([
|
subprocess.run([
|
||||||
settings.PDFTK,
|
settings.PDFTK,
|
||||||
os.path.join(d, 'front.pdf'),
|
os.path.join(d, 'front.pdf'),
|
||||||
'background',
|
'multibackground',
|
||||||
os.path.join(d, 'back.pdf'),
|
os.path.join(d, 'back.pdf'),
|
||||||
'output',
|
'output',
|
||||||
os.path.join(d, 'out.pdf'),
|
os.path.join(d, 'out.pdf'),
|
||||||
@@ -794,8 +889,8 @@ class Renderer:
|
|||||||
new_pdf = PdfFileReader(buffer)
|
new_pdf = PdfFileReader(buffer)
|
||||||
output = PdfFileWriter()
|
output = PdfFileWriter()
|
||||||
|
|
||||||
for page in new_pdf.pages:
|
for i, page in enumerate(new_pdf.pages):
|
||||||
bg_page = copy.copy(self.bg_pdf.getPage(0))
|
bg_page = copy.copy(self.bg_pdf.getPage(i))
|
||||||
bg_page.mergePage(page)
|
bg_page.mergePage(page)
|
||||||
output.addPage(bg_page)
|
output.addPage(bg_page)
|
||||||
|
|
||||||
|
|||||||
@@ -273,6 +273,11 @@ class RelativeDateTimeField(forms.MultiValueField):
|
|||||||
minutes_before=None
|
minutes_before=None
|
||||||
))
|
))
|
||||||
|
|
||||||
|
def has_changed(self, initial, data):
|
||||||
|
if initial is None:
|
||||||
|
initial = self.widget.decompress(initial)
|
||||||
|
return super().has_changed(initial, data)
|
||||||
|
|
||||||
def clean(self, value):
|
def clean(self, value):
|
||||||
if value[0] == 'absolute' and not value[1]:
|
if value[0] == 'absolute' and not value[1]:
|
||||||
raise ValidationError(self.error_messages['incomplete'])
|
raise ValidationError(self.error_messages['incomplete'])
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_us
|
|||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
changed = position.secret != secret
|
changed = position.secret != secret
|
||||||
if position.secret and changed and gen.use_revocation_list:
|
if position.secret and changed and gen.use_revocation_list and position.pk:
|
||||||
position.revoked_secrets.create(event=event, secret=position.secret)
|
position.revoked_secrets.create(event=event, secret=position.secret)
|
||||||
position.secret = secret
|
position.secret = secret
|
||||||
if save and changed:
|
if save and changed:
|
||||||
|
|||||||
@@ -214,8 +214,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
|||||||
refund_amount = o.payment_refund_sum
|
refund_amount = o.payment_refund_sum
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if auto_refund:
|
if auto_refund or manual_refund:
|
||||||
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
|
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True,
|
||||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||||
comment=gettext('Event canceled'))
|
comment=gettext('Event canceled'))
|
||||||
@@ -272,8 +272,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
|||||||
ocm.commit()
|
ocm.commit()
|
||||||
refund_amount = o.payment_refund_sum - o.total
|
refund_amount = o.payment_refund_sum - o.total
|
||||||
|
|
||||||
if auto_refund:
|
if auto_refund or manual_refund:
|
||||||
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
|
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True,
|
||||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||||
comment=gettext('Event canceled'))
|
comment=gettext('Event canceled'))
|
||||||
|
|||||||
@@ -426,10 +426,10 @@ class CartManager:
|
|||||||
if not cp.includes_tax:
|
if not cp.includes_tax:
|
||||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||||
cp_is_net=True, bundled_sum=bundled_sum)
|
cp_is_net=True, bundled_sum=bundled_sum)
|
||||||
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
|
price = TaxedPrice(net=price.net, gross=price.net, rate=Decimal('0'), tax=Decimal('0'), name='')
|
||||||
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
|
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
|
||||||
cp_is_net=True, bundled_sum=bundled_sum)
|
cp_is_net=True, bundled_sum=bundled_sum)
|
||||||
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=0, tax=0, name='')
|
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=Decimal('0'), tax=Decimal('0'), name='')
|
||||||
else:
|
else:
|
||||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||||
bundled_sum=bundled_sum)
|
bundled_sum=bundled_sum)
|
||||||
@@ -1106,10 +1106,11 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
|
|||||||
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
|
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
|
||||||
|
|
||||||
if pos.tax_rate != rate:
|
if pos.tax_rate != rate:
|
||||||
current_net = pos.price - pos.tax_value
|
if not pos.item.tax_rule.keep_gross_if_rate_changes:
|
||||||
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
|
current_net = pos.price - pos.tax_value
|
||||||
totaldiff += new_gross - pos.price
|
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
|
||||||
pos.price = new_gross
|
totaldiff += new_gross - pos.price
|
||||||
|
pos.price = new_gross
|
||||||
pos.includes_tax = rate != Decimal('0.00')
|
pos.includes_tax = rate != Decimal('0.00')
|
||||||
pos.override_tax_rate = rate
|
pos.override_tax_rate = rate
|
||||||
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])
|
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ import pytz
|
|||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
BooleanField, Count, ExpressionWrapper, F, IntegerField, OuterRef, Q,
|
BooleanField, Count, ExpressionWrapper, F, IntegerField, Max, Min,
|
||||||
Subquery, Value,
|
OuterRef, Q, Subquery, Value,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce, TruncDate
|
from django.db.models.functions import Coalesce, TruncDate
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@@ -60,7 +60,7 @@ from pretix.helpers.jsonlogic import Logic
|
|||||||
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
|
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
|
||||||
from pretix.helpers.jsonlogic_query import (
|
from pretix.helpers.jsonlogic_query import (
|
||||||
Equal, GreaterEqualThan, GreaterThan, InList, LowerEqualThan, LowerThan,
|
Equal, GreaterEqualThan, GreaterThan, InList, LowerEqualThan, LowerThan,
|
||||||
tolerance,
|
MinutesSince, tolerance,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -210,19 +210,60 @@ def _logic_explain(rules, ev, rule_data):
|
|||||||
elif var == 'product' or var == 'variation':
|
elif var == 'product' or var == 'variation':
|
||||||
var_weights[vname] = (1000, 0)
|
var_weights[vname] = (1000, 0)
|
||||||
var_texts[vname] = _('Ticket type not allowed')
|
var_texts[vname] = _('Ticket type not allowed')
|
||||||
elif var in ('entries_number', 'entries_today', 'entries_days'):
|
elif var in ('entries_number', 'entries_today', 'entries_days', 'minutes_since_last_entry', 'minutes_since_first_entry', 'now_isoweekday'):
|
||||||
w = {
|
w = {
|
||||||
|
'minutes_since_first_entry': 80,
|
||||||
|
'minutes_since_last_entry': 90,
|
||||||
'entries_days': 100,
|
'entries_days': 100,
|
||||||
'entries_number': 120,
|
'entries_number': 120,
|
||||||
'entries_today': 140,
|
'entries_today': 140,
|
||||||
|
'now_isoweekday': 210,
|
||||||
|
}
|
||||||
|
operator_weights = {
|
||||||
|
'==': 2,
|
||||||
|
'<': 1,
|
||||||
|
'<=': 1,
|
||||||
|
'>': 1,
|
||||||
|
'>=': 1,
|
||||||
|
'!=': 3,
|
||||||
}
|
}
|
||||||
l = {
|
l = {
|
||||||
|
'minutes_since_last_entry': _('time since last entry'),
|
||||||
|
'minutes_since_first_entry': _('time since first entry'),
|
||||||
'entries_days': _('number of days with an entry'),
|
'entries_days': _('number of days with an entry'),
|
||||||
'entries_number': _('number of entries'),
|
'entries_number': _('number of entries'),
|
||||||
'entries_today': _('number of entries today'),
|
'entries_today': _('number of entries today'),
|
||||||
|
'now_isoweekday': _('week day'),
|
||||||
}
|
}
|
||||||
compare_to = rhs[0]
|
compare_to = rhs[0]
|
||||||
var_weights[vname] = (w[var], abs(compare_to - rule_data[var]))
|
penalty = 0
|
||||||
|
|
||||||
|
if var in ('minutes_since_last_entry', 'minutes_since_first_entry'):
|
||||||
|
is_comparison_to_minus_one = (
|
||||||
|
(operator == '<' and compare_to <= 0) or
|
||||||
|
(operator == '<=' and compare_to < 0) or
|
||||||
|
(operator == '>=' and compare_to < 0) or
|
||||||
|
(operator == '>' and compare_to <= 0) or
|
||||||
|
(operator == '==' and compare_to == -1) or
|
||||||
|
(operator == '!=' and compare_to == -1)
|
||||||
|
)
|
||||||
|
if is_comparison_to_minus_one:
|
||||||
|
# These are "technical" comparisons without real meaning, we don't want to show them.
|
||||||
|
penalty = 1000
|
||||||
|
|
||||||
|
var_weights[vname] = (w[var] + operator_weights.get(operator, 0) + penalty, abs(compare_to - rule_data[var]))
|
||||||
|
|
||||||
|
if var == 'now_isoweekday':
|
||||||
|
compare_to = {
|
||||||
|
1: _('Monday'),
|
||||||
|
2: _('Tuesday'),
|
||||||
|
3: _('Wednesday'),
|
||||||
|
4: _('Thursday'),
|
||||||
|
5: _('Friday'),
|
||||||
|
6: _('Saturday'),
|
||||||
|
7: _('Sunday'),
|
||||||
|
}.get(compare_to, compare_to)
|
||||||
|
|
||||||
if operator == '==':
|
if operator == '==':
|
||||||
var_texts[vname] = _('{variable} is not {value}').format(variable=l[var], value=compare_to)
|
var_texts[vname] = _('{variable} is not {value}').format(variable=l[var], value=compare_to)
|
||||||
elif operator in ('<', '<='):
|
elif operator in ('<', '<='):
|
||||||
@@ -231,6 +272,7 @@ def _logic_explain(rules, ev, rule_data):
|
|||||||
var_texts[vname] = _('Minimum {variable} exceeded').format(variable=l[var])
|
var_texts[vname] = _('Minimum {variable} exceeded').format(variable=l[var])
|
||||||
elif operator == '!=':
|
elif operator == '!=':
|
||||||
var_texts[vname] = _('{variable} is {value}').format(variable=l[var], value=compare_to)
|
var_texts[vname] = _('{variable} is {value}').format(variable=l[var], value=compare_to)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f'Unknown variable {var}')
|
raise ValueError(f'Unknown variable {var}')
|
||||||
|
|
||||||
@@ -289,6 +331,11 @@ class LazyRuleVars:
|
|||||||
def now(self):
|
def now(self):
|
||||||
return self._dt
|
return self._dt
|
||||||
|
|
||||||
|
@property
|
||||||
|
def now_isoweekday(self):
|
||||||
|
tz = self._clist.event.timezone
|
||||||
|
return self._dt.astimezone(tz).isoweekday()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def product(self):
|
def product(self):
|
||||||
return self._position.item_id
|
return self._position.item_id
|
||||||
@@ -315,6 +362,30 @@ class LazyRuleVars:
|
|||||||
day=TruncDate('datetime', tzinfo=tz)
|
day=TruncDate('datetime', tzinfo=tz)
|
||||||
).values('day').distinct().count()
|
).values('day').distinct().count()
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def minutes_since_last_entry(self):
|
||||||
|
tz = self._clist.event.timezone
|
||||||
|
with override(tz):
|
||||||
|
last_entry = self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).order_by('datetime').last()
|
||||||
|
if last_entry is None:
|
||||||
|
# Returning "None" would be "correct", but the handling of "None" in JSON logic is inconsistent
|
||||||
|
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least
|
||||||
|
# consistent.
|
||||||
|
return -1
|
||||||
|
return (now() - last_entry.datetime).total_seconds() // 60
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def minutes_since_first_entry(self):
|
||||||
|
tz = self._clist.event.timezone
|
||||||
|
with override(tz):
|
||||||
|
last_entry = self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).order_by('datetime').first()
|
||||||
|
if last_entry is None:
|
||||||
|
# Returning "None" would be "correct", but the handling of "None" in JSON logic is inconsistent
|
||||||
|
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least
|
||||||
|
# consistent.
|
||||||
|
return -1
|
||||||
|
return (now() - last_entry.datetime).total_seconds() // 60
|
||||||
|
|
||||||
|
|
||||||
class SQLLogic:
|
class SQLLogic:
|
||||||
"""
|
"""
|
||||||
@@ -373,22 +444,22 @@ class SQLLogic:
|
|||||||
).astimezone(pytz.UTC))
|
).astimezone(pytz.UTC))
|
||||||
elif values[0] == 'date_from':
|
elif values[0] == 'date_from':
|
||||||
return Coalesce(
|
return Coalesce(
|
||||||
F(f'subevent__date_from'),
|
F('subevent__date_from'),
|
||||||
F(f'order__event__date_from'),
|
F('order__event__date_from'),
|
||||||
)
|
)
|
||||||
elif values[0] == 'date_to':
|
elif values[0] == 'date_to':
|
||||||
return Coalesce(
|
return Coalesce(
|
||||||
F(f'subevent__date_to'),
|
F('subevent__date_to'),
|
||||||
F(f'subevent__date_from'),
|
F('subevent__date_from'),
|
||||||
F(f'order__event__date_to'),
|
F('order__event__date_to'),
|
||||||
F(f'order__event__date_from'),
|
F('order__event__date_from'),
|
||||||
)
|
)
|
||||||
elif values[0] == 'date_admission':
|
elif values[0] == 'date_admission':
|
||||||
return Coalesce(
|
return Coalesce(
|
||||||
F(f'subevent__date_admission'),
|
F('subevent__date_admission'),
|
||||||
F(f'subevent__date_from'),
|
F('subevent__date_from'),
|
||||||
F(f'order__event__date_admission'),
|
F('order__event__date_admission'),
|
||||||
F(f'order__event__date_from'),
|
F('order__event__date_from'),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f'Unknown time type {values[0]}')
|
raise ValueError(f'Unknown time type {values[0]}')
|
||||||
@@ -399,6 +470,8 @@ class SQLLogic:
|
|||||||
elif operator == 'var':
|
elif operator == 'var':
|
||||||
if values[0] == 'now':
|
if values[0] == 'now':
|
||||||
return Value(now().astimezone(pytz.UTC))
|
return Value(now().astimezone(pytz.UTC))
|
||||||
|
elif values[0] == 'now_isoweekday':
|
||||||
|
return Value(now().astimezone(self.list.event.timezone).isoweekday())
|
||||||
elif values[0] == 'product':
|
elif values[0] == 'product':
|
||||||
return F('item_id')
|
return F('item_id')
|
||||||
elif values[0] == 'variation':
|
elif values[0] == 'variation':
|
||||||
@@ -450,6 +523,38 @@ class SQLLogic:
|
|||||||
Value(0),
|
Value(0),
|
||||||
output_field=IntegerField()
|
output_field=IntegerField()
|
||||||
)
|
)
|
||||||
|
elif values[0] == 'minutes_since_last_entry':
|
||||||
|
sq_last_entry = Subquery(
|
||||||
|
Checkin.objects.filter(
|
||||||
|
position_id=OuterRef('pk'),
|
||||||
|
type=Checkin.TYPE_ENTRY,
|
||||||
|
list_id=self.list.pk,
|
||||||
|
).values('position_id').order_by().annotate(
|
||||||
|
m=Max('datetime')
|
||||||
|
).values('m')
|
||||||
|
)
|
||||||
|
|
||||||
|
return Coalesce(
|
||||||
|
MinutesSince(sq_last_entry),
|
||||||
|
Value(-1),
|
||||||
|
output_field=IntegerField()
|
||||||
|
)
|
||||||
|
elif values[0] == 'minutes_since_first_entry':
|
||||||
|
sq_last_entry = Subquery(
|
||||||
|
Checkin.objects.filter(
|
||||||
|
position_id=OuterRef('pk'),
|
||||||
|
type=Checkin.TYPE_ENTRY,
|
||||||
|
list_id=self.list.pk,
|
||||||
|
).values('position_id').order_by().annotate(
|
||||||
|
m=Min('datetime')
|
||||||
|
).values('m')
|
||||||
|
)
|
||||||
|
|
||||||
|
return Coalesce(
|
||||||
|
MinutesSince(sq_last_entry),
|
||||||
|
Value(-1),
|
||||||
|
output_field=IntegerField()
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f'Unknown operator {operator}')
|
raise ValueError(f'Unknown operator {operator}')
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
|
|||||||
with language(event.settings.locale, event.settings.region), override(event.settings.timezone):
|
with language(event.settings.locale, event.settings.region), override(event.settings.timezone):
|
||||||
responses = register_data_exporters.send(event)
|
responses = register_data_exporters.send(event)
|
||||||
for receiver, response in responses:
|
for receiver, response in responses:
|
||||||
|
if not response:
|
||||||
|
continue
|
||||||
ex = response(event, event.organizer, set_progress)
|
ex = response(event, event.organizer, set_progress)
|
||||||
if ex.identifier == provider:
|
if ex.identifier == provider:
|
||||||
d = ex.render(form_data)
|
d = ex.render(form_data)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
|||||||
payment = ""
|
payment = ""
|
||||||
if invoice.event.settings.invoice_include_expire_date and invoice.order.status == Order.STATUS_PENDING:
|
if invoice.event.settings.invoice_include_expire_date and invoice.order.status == Order.STATUS_PENDING:
|
||||||
if payment:
|
if payment:
|
||||||
payment += "<br />"
|
payment += "<br /><br />"
|
||||||
payment += pgettext("invoice", "Please complete your payment before {expire_date}.").format(
|
payment += pgettext("invoice", "Please complete your payment before {expire_date}.").format(
|
||||||
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
|
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
@@ -51,6 +52,7 @@ from bs4 import BeautifulSoup
|
|||||||
from celery import chain
|
from celery import chain
|
||||||
from celery.exceptions import MaxRetriesExceededError
|
from celery.exceptions import MaxRetriesExceededError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
from django.core.mail import (
|
from django.core.mail import (
|
||||||
EmailMultiAlternatives, SafeMIMEMultipart, get_connection,
|
EmailMultiAlternatives, SafeMIMEMultipart, get_connection,
|
||||||
)
|
)
|
||||||
@@ -73,8 +75,9 @@ from pretix.base.services.tasks import TransactionAwareTask
|
|||||||
from pretix.base.services.tickets import get_tickets_for_order
|
from pretix.base.services.tickets import get_tickets_for_order
|
||||||
from pretix.base.signals import email_filter, global_email_filter
|
from pretix.base.signals import email_filter, global_email_filter
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
|
from pretix.helpers.hierarkey import clean_filename
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
from pretix.presale.ical import get_ical
|
from pretix.presale.ical import get_private_icals
|
||||||
|
|
||||||
logger = logging.getLogger('pretix.base.mail')
|
logger = logging.getLogger('pretix.base.mail')
|
||||||
INVALID_ADDRESS = 'invalid-pretix-mail-address'
|
INVALID_ADDRESS = 'invalid-pretix-mail-address'
|
||||||
@@ -94,7 +97,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
|
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
|
||||||
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
|
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
|
||||||
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
|
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
|
||||||
attach_ical=False, attach_cached_files: Sequence = None):
|
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None):
|
||||||
"""
|
"""
|
||||||
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
||||||
|
|
||||||
@@ -142,6 +145,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
|
|
||||||
:param attach_cached_files: A list of cached file to attach to this email.
|
:param attach_cached_files: A list of cached file to attach to this email.
|
||||||
|
|
||||||
|
:param attach_other_files: A list of file paths on our storage to attach.
|
||||||
|
|
||||||
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
|
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
|
||||||
that the email has been sent, just that it has been queued by the email backend.
|
that the email has been sent, just that it has been queued by the email backend.
|
||||||
"""
|
"""
|
||||||
@@ -212,7 +217,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
for bcc_mail in settings_holder.settings.mail_bcc.split(','):
|
for bcc_mail in settings_holder.settings.mail_bcc.split(','):
|
||||||
bcc.append(bcc_mail.strip())
|
bcc.append(bcc_mail.strip())
|
||||||
|
|
||||||
if settings_holder.settings.mail_from == settings.DEFAULT_FROM_EMAIL and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
|
if settings_holder.settings.mail_from in (settings.DEFAULT_FROM_EMAIL, settings.MAIL_FROM_ORGANIZERS) \
|
||||||
|
and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
|
||||||
headers['Reply-To'] = settings_holder.settings.contact_mail
|
headers['Reply-To'] = settings_holder.settings.contact_mail
|
||||||
|
|
||||||
prefix = settings_holder.settings.get('mail_prefix')
|
prefix = settings_holder.settings.get('mail_prefix')
|
||||||
@@ -301,6 +307,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
organizer=organizer.pk if organizer else None,
|
organizer=organizer.pk if organizer else None,
|
||||||
customer=customer.pk if customer else None,
|
customer=customer.pk if customer else None,
|
||||||
attach_cached_files=[(cf.id if isinstance(cf, CachedFile) else cf) for cf in attach_cached_files] if attach_cached_files else [],
|
attach_cached_files=[(cf.id if isinstance(cf, CachedFile) else cf) for cf in attach_cached_files] if attach_cached_files else [],
|
||||||
|
attach_other_files=attach_other_files,
|
||||||
)
|
)
|
||||||
|
|
||||||
if invoices:
|
if invoices:
|
||||||
@@ -338,7 +345,8 @@ class CustomEmail(EmailMultiAlternatives):
|
|||||||
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
|
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||||
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
|
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
|
||||||
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
|
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
|
||||||
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None) -> bool:
|
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None,
|
||||||
|
attach_other_files: List[str] = None) -> bool:
|
||||||
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||||
if html is not None:
|
if html is not None:
|
||||||
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
|
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
|
||||||
@@ -422,18 +430,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if attach_ical:
|
if attach_ical:
|
||||||
ical_events = set()
|
for i, cal in enumerate(get_private_icals(event, [position] if position else order.positions.all())):
|
||||||
if event.has_subevents:
|
|
||||||
if position:
|
|
||||||
ical_events.add(position.subevent)
|
|
||||||
else:
|
|
||||||
for p in order.positions.all():
|
|
||||||
ical_events.add(p.subevent)
|
|
||||||
else:
|
|
||||||
ical_events.add(order.event)
|
|
||||||
|
|
||||||
for i, e in enumerate(ical_events):
|
|
||||||
cal = get_ical([e])
|
|
||||||
email.attach('event-{}.ics'.format(i), cal.serialize(), 'text/calendar')
|
email.attach('event-{}.ics'.format(i), cal.serialize(), 'text/calendar')
|
||||||
|
|
||||||
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
|
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
|
||||||
@@ -455,6 +452,20 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
|||||||
logger.exception('Could not attach invoice to email')
|
logger.exception('Could not attach invoice to email')
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if attach_other_files:
|
||||||
|
for fname in attach_other_files:
|
||||||
|
ftype, _ = mimetypes.guess_type(fname)
|
||||||
|
data = default_storage.open(fname).read()
|
||||||
|
try:
|
||||||
|
email.attach(
|
||||||
|
clean_filename(os.path.basename(fname)),
|
||||||
|
data,
|
||||||
|
ftype
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
logger.exception('Could not attach file to email')
|
||||||
|
pass
|
||||||
|
|
||||||
if attach_cached_files:
|
if attach_cached_files:
|
||||||
for cf in CachedFile.objects.filter(id__in=attach_cached_files):
|
for cf in CachedFile.objects.filter(id__in=attach_cached_files):
|
||||||
if cf.file:
|
if cf.file:
|
||||||
@@ -568,7 +579,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
raise e
|
raise e
|
||||||
if logger:
|
if log_target:
|
||||||
log_target.log_action(
|
log_target.log_action(
|
||||||
'pretix.email.error',
|
'pretix.email.error',
|
||||||
data={
|
data={
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ def send_notification_mail(notification: Notification, user: User):
|
|||||||
),
|
),
|
||||||
'body': body_plain,
|
'body': body_plain,
|
||||||
'html': body_html,
|
'html': body_html,
|
||||||
'sender': settings.MAIL_FROM,
|
'sender': settings.MAIL_FROM_NOTIFICATIONS,
|
||||||
'headers': {},
|
'headers': {},
|
||||||
'user': user.pk
|
'user': user.pk
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ from django.db.transaction import get_connection
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import make_aware, now
|
from django.utils.timezone import make_aware, now
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _, gettext_lazy
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
from pretix.api.models import OAuthApplication
|
from pretix.api.models import OAuthApplication
|
||||||
@@ -384,7 +384,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
|||||||
|
|
||||||
|
|
||||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
|
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
|
||||||
cancellation_fee=None, keep_fees=None, cancel_invoice=True):
|
cancellation_fee=None, keep_fees=None, cancel_invoice=True, comment=None):
|
||||||
"""
|
"""
|
||||||
Mark this order as canceled
|
Mark this order as canceled
|
||||||
:param order: The order to change
|
:param order: The order to change
|
||||||
@@ -481,7 +481,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
|||||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||||
|
|
||||||
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device,
|
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device,
|
||||||
data={'cancellation_fee': cancellation_fee})
|
data={'cancellation_fee': cancellation_fee, 'comment': comment})
|
||||||
order.cancellation_requests.all().delete()
|
order.cancellation_requests.all().delete()
|
||||||
|
|
||||||
order.create_transactions()
|
order.create_transactions()
|
||||||
@@ -489,7 +489,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
|||||||
if send_mail:
|
if send_mail:
|
||||||
email_template = order.event.settings.mail_text_order_canceled
|
email_template = order.event.settings.mail_text_order_canceled
|
||||||
with language(order.locale, order.event.settings.region):
|
with language(order.locale, order.event.settings.region):
|
||||||
email_context = get_email_context(event=order.event, order=order)
|
email_context = get_email_context(event=order.event, order=order, comment=comment or "")
|
||||||
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
|
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
|
||||||
try:
|
try:
|
||||||
order.send_mail(
|
order.send_mail(
|
||||||
@@ -700,7 +700,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
|||||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||||
changed_prices[cp.pk] = bprice
|
changed_prices[cp.pk] = bprice
|
||||||
else:
|
else:
|
||||||
bundled_sum = 0
|
bundled_sum = Decimal('0.00')
|
||||||
if not cp.addon_to_id:
|
if not cp.addon_to_id:
|
||||||
for bundledp in cp.addons.all():
|
for bundledp in cp.addons.all():
|
||||||
if bundledp.is_bundled:
|
if bundledp.is_bundled:
|
||||||
@@ -856,7 +856,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
|||||||
total=total,
|
total=total,
|
||||||
testmode=True if sales_channel.testmode_supported and event.testmode else False,
|
testmode=True if sales_channel.testmode_supported and event.testmode else False,
|
||||||
meta_info=json.dumps(meta_info or {}),
|
meta_info=json.dumps(meta_info or {}),
|
||||||
require_approval=any(p.item.require_approval for p in positions),
|
require_approval=any(p.requires_approval(invoice_address=address) for p in positions),
|
||||||
sales_channel=sales_channel.identifier,
|
sales_channel=sales_channel.identifier,
|
||||||
customer=customer,
|
customer=customer,
|
||||||
)
|
)
|
||||||
@@ -932,33 +932,38 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
|||||||
|
|
||||||
|
|
||||||
def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str,
|
def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str,
|
||||||
invoice, payment: OrderPayment):
|
invoice, payment: OrderPayment, is_free=False):
|
||||||
email_context = get_email_context(event=event, order=order, payment=payment if pprov else None)
|
email_context = get_email_context(event=event, order=order, payment=payment if pprov else None)
|
||||||
email_subject = _('Your order: %(code)s') % {'code': order.code}
|
email_subject = gettext_lazy('Your order: {code}')
|
||||||
try:
|
try:
|
||||||
order.send_mail(
|
order.send_mail(
|
||||||
email_subject, email_template, email_context,
|
email_subject, email_template, email_context,
|
||||||
log_entry,
|
log_entry,
|
||||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
||||||
attach_tickets=True,
|
attach_tickets=True,
|
||||||
attach_ical=event.settings.mail_attach_ical
|
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
|
||||||
|
attach_other_files=[a for a in [
|
||||||
|
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||||
|
] if a],
|
||||||
)
|
)
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
logger.exception('Order received email could not be sent')
|
logger.exception('Order received email could not be sent')
|
||||||
|
|
||||||
|
|
||||||
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str):
|
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str, is_free=False):
|
||||||
email_context = get_email_context(event=event, order=order, position=position)
|
email_context = get_email_context(event=event, order=order, position=position)
|
||||||
email_subject = _('Your event registration: %(code)s') % {'code': order.code}
|
email_subject = gettext_lazy('Your event registration: {code}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
order.send_mail(
|
position.send_mail(
|
||||||
email_subject, email_template, email_context,
|
email_subject, email_template, email_context,
|
||||||
log_entry,
|
log_entry,
|
||||||
invoices=[],
|
invoices=[],
|
||||||
attach_tickets=True,
|
attach_tickets=True,
|
||||||
position=position,
|
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
|
||||||
attach_ical=event.settings.mail_attach_ical
|
attach_other_files=[a for a in [
|
||||||
|
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||||
|
] if a],
|
||||||
)
|
)
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
logger.exception('Order received email could not be sent to attendee')
|
logger.exception('Order received email could not be sent to attendee')
|
||||||
@@ -1064,11 +1069,13 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
|||||||
email_attendees_template = event.settings.mail_text_order_placed_attendee
|
email_attendees_template = event.settings.mail_text_order_placed_attendee
|
||||||
|
|
||||||
if sales_channel in event.settings.mail_sales_channel_placed_paid:
|
if sales_channel in event.settings.mail_sales_channel_placed_paid:
|
||||||
_order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment)
|
_order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment,
|
||||||
|
is_free=free_order_flow)
|
||||||
if email_attendees:
|
if email_attendees:
|
||||||
for p in order.positions.all():
|
for p in order.positions.all():
|
||||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||||
_order_placed_email_attendee(event, order, p, email_attendees_template, log_entry)
|
_order_placed_email_attendee(event, order, p, email_attendees_template, log_entry,
|
||||||
|
is_free=free_order_flow)
|
||||||
|
|
||||||
return order.id
|
return order.id
|
||||||
|
|
||||||
@@ -1460,7 +1467,7 @@ class OrderChangeManager:
|
|||||||
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
|
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
|
||||||
self._invoice_dirty = True
|
self._invoice_dirty = True
|
||||||
|
|
||||||
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
|
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
|
||||||
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None):
|
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None):
|
||||||
if isinstance(seat, str):
|
if isinstance(seat, str):
|
||||||
if not seat:
|
if not seat:
|
||||||
@@ -1485,6 +1492,8 @@ class OrderChangeManager:
|
|||||||
|
|
||||||
if price is None:
|
if price is None:
|
||||||
raise OrderError(self.error_messages['product_invalid'])
|
raise OrderError(self.error_messages['product_invalid'])
|
||||||
|
if item.variations.exists() and not variation:
|
||||||
|
raise OrderError(self.error_messages['product_without_variation'])
|
||||||
if not addon_to and item.category and item.category.is_addon:
|
if not addon_to and item.category and item.category.is_addon:
|
||||||
raise OrderError(self.error_messages['addon_to_required'])
|
raise OrderError(self.error_messages['addon_to_required'])
|
||||||
if addon_to:
|
if addon_to:
|
||||||
@@ -1520,6 +1529,8 @@ class OrderChangeManager:
|
|||||||
self._invoice_dirty = True
|
self._invoice_dirty = True
|
||||||
|
|
||||||
self._operations.append(self.SplitOperation(position))
|
self._operations.append(self.SplitOperation(position))
|
||||||
|
for a in position.addons.all():
|
||||||
|
self._operations.append(self.SplitOperation(a))
|
||||||
|
|
||||||
def set_addons(self, addons):
|
def set_addons(self, addons):
|
||||||
if self._operations:
|
if self._operations:
|
||||||
@@ -1586,21 +1597,22 @@ class OrderChangeManager:
|
|||||||
|
|
||||||
op = opcache[a['addon_to']]
|
op = opcache[a['addon_to']]
|
||||||
item = _items_cache[a['item']]
|
item = _items_cache[a['item']]
|
||||||
|
subevent = op.subevent # for now, we might lift this requirement later
|
||||||
variation = _variations_cache[a['variation']] if a['variation'] is not None else None
|
variation = _variations_cache[a['variation']] if a['variation'] is not None else None
|
||||||
|
|
||||||
if item.category_id not in available_categories[op.pk]:
|
if item.category_id not in available_categories[op.pk]:
|
||||||
raise OrderError(error_messages['addon_invalid_base'])
|
raise OrderError(error_messages['addon_invalid_base'])
|
||||||
|
|
||||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||||
quotas = list(item.quotas.filter(subevent=op.subevent)
|
quotas = list(item.quotas.filter(subevent=subevent)
|
||||||
if variation is None else variation.quotas.filter(subevent=op.subevent))
|
if variation is None else variation.quotas.filter(subevent=subevent))
|
||||||
if not quotas:
|
if not quotas:
|
||||||
raise OrderError(error_messages['unavailable'])
|
raise OrderError(error_messages['unavailable'])
|
||||||
|
|
||||||
if (a['item'], a['variation']) in input_addons[op.id]:
|
if (a['item'], a['variation']) in input_addons[op.id]:
|
||||||
raise OrderError(error_messages['addon_duplicate_item'])
|
raise OrderError(error_messages['addon_duplicate_item'])
|
||||||
|
|
||||||
if item.require_voucher or op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher):
|
if item.require_voucher or item.hide_without_voucher or (variation and variation.hide_without_voucher):
|
||||||
raise OrderError(error_messages['voucher_required'])
|
raise OrderError(error_messages['voucher_required'])
|
||||||
|
|
||||||
if not item.is_available() or (variation and not variation.is_available()):
|
if not item.is_available() or (variation and not variation.is_available()):
|
||||||
@@ -1610,11 +1622,11 @@ class OrderChangeManager:
|
|||||||
variation and self.order.sales_channel not in variation.sales_channels):
|
variation and self.order.sales_channel not in variation.sales_channels):
|
||||||
raise OrderError(error_messages['unavailable'])
|
raise OrderError(error_messages['unavailable'])
|
||||||
|
|
||||||
if op.subevent and item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
|
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available():
|
||||||
raise OrderError(error_messages['not_for_sale'])
|
raise OrderError(error_messages['not_for_sale'])
|
||||||
|
|
||||||
if op.subevent and variation and variation.pk in op.subevent.var_overrides and \
|
if subevent and variation and variation.pk in subevent.var_overrides and \
|
||||||
not op.subevent.var_overrides[variation.pk].is_available():
|
not subevent.var_overrides[variation.pk].is_available():
|
||||||
raise OrderError(error_messages['not_for_sale'])
|
raise OrderError(error_messages['not_for_sale'])
|
||||||
|
|
||||||
if item.has_variations and not variation:
|
if item.has_variations and not variation:
|
||||||
@@ -1623,10 +1635,10 @@ class OrderChangeManager:
|
|||||||
if variation and variation.item_id != item.pk:
|
if variation and variation.item_id != item.pk:
|
||||||
raise OrderError(error_messages['not_for_sale'])
|
raise OrderError(error_messages['not_for_sale'])
|
||||||
|
|
||||||
if op.subevent and op.subevent.presale_start and now() < op.subevent.presale_start:
|
if subevent and subevent.presale_start and now() < subevent.presale_start:
|
||||||
raise OrderError(error_messages['not_started'])
|
raise OrderError(error_messages['not_started'])
|
||||||
|
|
||||||
if (op.subevent and op.subevent.presale_has_ended) or self.event.presale_has_ended:
|
if (subevent and subevent.presale_has_ended) or self.event.presale_has_ended:
|
||||||
raise OrderError(error_messages['ended'])
|
raise OrderError(error_messages['ended'])
|
||||||
|
|
||||||
if item.require_bundling:
|
if item.require_bundling:
|
||||||
@@ -2065,7 +2077,7 @@ class OrderChangeManager:
|
|||||||
split_order.code = None
|
split_order.code = None
|
||||||
split_order.datetime = now()
|
split_order.datetime = now()
|
||||||
split_order.secret = generate_secret()
|
split_order.secret = generate_secret()
|
||||||
split_order.require_approval = self.order.require_approval and any(p.item.require_approval for p in split_positions)
|
split_order.require_approval = self.order.require_approval and any(p.requires_approval(invoice_address=self._invoice_address) for p in split_positions)
|
||||||
split_order.save()
|
split_order.save()
|
||||||
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
|
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
|
||||||
'original_order': self.order.code
|
'original_order': self.order.code
|
||||||
@@ -2317,10 +2329,10 @@ class OrderChangeManager:
|
|||||||
except TaxRule.SaleNotAllowed:
|
except TaxRule.SaleNotAllowed:
|
||||||
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
||||||
self._recalculate_total_and_payment_fee()
|
self._recalculate_total_and_payment_fee()
|
||||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
|
||||||
self._reissue_invoice()
|
|
||||||
self._check_paid_price_change()
|
self._check_paid_price_change()
|
||||||
self._check_paid_to_free()
|
self._check_paid_to_free()
|
||||||
|
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||||
|
self._reissue_invoice()
|
||||||
self._clear_tickets_cache()
|
self._clear_tickets_cache()
|
||||||
self.order.touch()
|
self.order.touch()
|
||||||
self.order.create_transactions()
|
self.order.create_transactions()
|
||||||
@@ -2375,7 +2387,8 @@ def perform_order(self, event: Event, payment_provider: str, positions: List[str
|
|||||||
_unset = object()
|
_unset = object()
|
||||||
|
|
||||||
|
|
||||||
def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=OrderRefund.REFUND_SOURCE_BUYER,
|
def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial=False,
|
||||||
|
source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||||
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None, comment=None):
|
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None, comment=None):
|
||||||
notify_admin = False
|
notify_admin = False
|
||||||
error = False
|
error = False
|
||||||
@@ -2385,9 +2398,9 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
|||||||
if refund_amount <= Decimal('0.00'):
|
if refund_amount <= Decimal('0.00'):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
can_auto_refund_sum = 0
|
||||||
|
|
||||||
if refund_as_giftcard:
|
if refund_as_giftcard:
|
||||||
proposals = {}
|
|
||||||
can_auto_refund = True
|
|
||||||
can_auto_refund_sum = refund_amount
|
can_auto_refund_sum = refund_amount
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
giftcard = order.event.organizer.issued_gift_cards.create(
|
giftcard = order.event.organizer.issued_gift_cards.create(
|
||||||
@@ -2427,42 +2440,41 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
|||||||
if r.state != OrderRefund.REFUND_STATE_DONE:
|
if r.state != OrderRefund.REFUND_STATE_DONE:
|
||||||
notify_admin = True
|
notify_admin = True
|
||||||
|
|
||||||
else:
|
elif auto_refund:
|
||||||
proposals = order.propose_auto_refunds(refund_amount)
|
proposals = order.propose_auto_refunds(refund_amount)
|
||||||
can_auto_refund_sum = sum(proposals.values())
|
can_auto_refund_sum = sum(proposals.values())
|
||||||
can_auto_refund = (allow_partial and can_auto_refund_sum) or can_auto_refund_sum == refund_amount
|
if (allow_partial and can_auto_refund_sum) or can_auto_refund_sum == refund_amount:
|
||||||
if can_auto_refund:
|
for p, value in proposals.items():
|
||||||
for p, value in proposals.items():
|
|
||||||
with transaction.atomic():
|
|
||||||
r = order.refunds.create(
|
|
||||||
payment=p,
|
|
||||||
source=source,
|
|
||||||
state=OrderRefund.REFUND_STATE_CREATED,
|
|
||||||
amount=value,
|
|
||||||
comment=comment,
|
|
||||||
provider=p.provider
|
|
||||||
)
|
|
||||||
order.log_action('pretix.event.order.refund.created', {
|
|
||||||
'local_id': r.local_id,
|
|
||||||
'provider': r.provider,
|
|
||||||
})
|
|
||||||
|
|
||||||
try:
|
|
||||||
r.payment_provider.execute_refund(r)
|
|
||||||
except PaymentException as e:
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
r.state = OrderRefund.REFUND_STATE_FAILED
|
r = order.refunds.create(
|
||||||
r.save()
|
payment=p,
|
||||||
order.log_action('pretix.event.order.refund.failed', {
|
source=source,
|
||||||
|
state=OrderRefund.REFUND_STATE_CREATED,
|
||||||
|
amount=value,
|
||||||
|
comment=comment,
|
||||||
|
provider=p.provider
|
||||||
|
)
|
||||||
|
order.log_action('pretix.event.order.refund.created', {
|
||||||
'local_id': r.local_id,
|
'local_id': r.local_id,
|
||||||
'provider': r.provider,
|
'provider': r.provider,
|
||||||
'error': str(e)
|
|
||||||
})
|
})
|
||||||
error = True
|
|
||||||
notify_admin = True
|
try:
|
||||||
else:
|
r.payment_provider.execute_refund(r)
|
||||||
if r.state not in (OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_DONE):
|
except PaymentException as e:
|
||||||
|
with transaction.atomic():
|
||||||
|
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||||
|
r.save()
|
||||||
|
order.log_action('pretix.event.order.refund.failed', {
|
||||||
|
'local_id': r.local_id,
|
||||||
|
'provider': r.provider,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
error = True
|
||||||
notify_admin = True
|
notify_admin = True
|
||||||
|
else:
|
||||||
|
if r.state not in (OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_DONE):
|
||||||
|
notify_admin = True
|
||||||
|
|
||||||
if refund_amount - can_auto_refund_sum > Decimal('0.00'):
|
if refund_amount - can_auto_refund_sum > Decimal('0.00'):
|
||||||
if manual_refund:
|
if manual_refund:
|
||||||
@@ -2494,15 +2506,15 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
|||||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||||
@scopes_disabled()
|
@scopes_disabled()
|
||||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||||
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False, comment=None,
|
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False,
|
||||||
cancel_invoice=True):
|
email_comment=None, refund_comment=None, cancel_invoice=True):
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
||||||
cancellation_fee, cancel_invoice=cancel_invoice)
|
cancellation_fee, cancel_invoice=cancel_invoice, comment=email_comment)
|
||||||
if try_auto_refund:
|
if try_auto_refund:
|
||||||
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
|
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
|
||||||
comment=comment)
|
comment=refund_comment)
|
||||||
return ret
|
return ret
|
||||||
except LockTimeoutException:
|
except LockTimeoutException:
|
||||||
self.retry()
|
self.retry()
|
||||||
|
|||||||
@@ -113,10 +113,8 @@ class QuotaAvailability:
|
|||||||
be a few minutes outdated. In this case, you may not rely on the results in the ``count_*`` properties.
|
be a few minutes outdated. In this case, you may not rely on the results in the ``count_*`` properties.
|
||||||
"""
|
"""
|
||||||
now_dt = now_dt or now()
|
now_dt = now_dt or now()
|
||||||
quotas = list(set(self._queue))
|
quota_ids_set = {q.id for q in self._queue}
|
||||||
quotas_original = list(self._queue)
|
if not quota_ids_set:
|
||||||
self._queue.clear()
|
|
||||||
if not quotas:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if allow_cache:
|
if allow_cache:
|
||||||
@@ -129,7 +127,7 @@ class QuotaAvailability:
|
|||||||
elif settings.HAS_REDIS:
|
elif settings.HAS_REDIS:
|
||||||
rc = get_redis_connection("redis")
|
rc = get_redis_connection("redis")
|
||||||
quotas_by_event = defaultdict(list)
|
quotas_by_event = defaultdict(list)
|
||||||
for q in quotas_original:
|
for q in [_q for _q in self._queue if _q.id in quota_ids_set]:
|
||||||
quotas_by_event[q.event_id].append(q)
|
quotas_by_event[q.event_id].append(q)
|
||||||
|
|
||||||
for eventid, evquotas in quotas_by_event.items():
|
for eventid, evquotas in quotas_by_event.items():
|
||||||
@@ -139,16 +137,19 @@ class QuotaAvailability:
|
|||||||
data = [rv for rv in redisval.decode().split(',')]
|
data = [rv for rv in redisval.decode().split(',')]
|
||||||
# Except for some rare situations, we don't want to use cache entries older than 2 minutes
|
# Except for some rare situations, we don't want to use cache entries older than 2 minutes
|
||||||
if time.time() - int(data[2]) < 120 or allow_cache_stale:
|
if time.time() - int(data[2]) < 120 or allow_cache_stale:
|
||||||
quotas_original.remove(q)
|
quota_ids_set.remove(q.id)
|
||||||
quotas.remove(q)
|
|
||||||
if data[1] == "None":
|
if data[1] == "None":
|
||||||
self.results[q] = int(data[0]), None
|
self.results[q] = int(data[0]), None
|
||||||
else:
|
else:
|
||||||
self.results[q] = int(data[0]), int(data[1])
|
self.results[q] = int(data[0]), int(data[1])
|
||||||
|
|
||||||
if not quotas:
|
if not quota_ids_set:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
quotas = [_q for _q in self._queue if _q.id in quota_ids_set]
|
||||||
|
quotas_original = list(quotas)
|
||||||
|
self._queue.clear()
|
||||||
|
|
||||||
self._compute(quotas, now_dt)
|
self._compute(quotas, now_dt)
|
||||||
|
|
||||||
for q in quotas_original:
|
for q in quotas_original:
|
||||||
@@ -284,15 +285,16 @@ class QuotaAvailability:
|
|||||||
seq = Q(subevent_id__in=subevents)
|
seq = Q(subevent_id__in=subevents)
|
||||||
if None in subevents:
|
if None in subevents:
|
||||||
seq |= Q(subevent__isnull=True)
|
seq |= Q(subevent__isnull=True)
|
||||||
|
quota_ids = {q.pk for q in quotas}
|
||||||
op_lookup = OrderPosition.objects.filter(
|
op_lookup = OrderPosition.objects.filter(
|
||||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
|
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
|
||||||
order__event_id__in=events,
|
order__event_id__in=events,
|
||||||
).filter(seq).filter(
|
).filter(seq).filter(
|
||||||
Q(
|
Q(
|
||||||
Q(variation_id__isnull=True) &
|
Q(variation_id__isnull=True) &
|
||||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||||
) | Q(
|
) | Q(
|
||||||
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas})
|
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids})
|
||||||
).order_by()
|
).order_by()
|
||||||
if any(q.release_after_exit for q in quotas):
|
if any(q.release_after_exit for q in quotas):
|
||||||
op_lookup = op_lookup.annotate(
|
op_lookup = op_lookup.annotate(
|
||||||
@@ -359,6 +361,7 @@ class QuotaAvailability:
|
|||||||
func = 'GREATEST'
|
func = 'GREATEST'
|
||||||
|
|
||||||
subevents = {q.subevent_id for q in quotas}
|
subevents = {q.subevent_id for q in quotas}
|
||||||
|
quota_ids = {q.pk for q in quotas}
|
||||||
seq = Q(subevent_id__in=subevents)
|
seq = Q(subevent_id__in=subevents)
|
||||||
if None in subevents:
|
if None in subevents:
|
||||||
seq |= Q(subevent__isnull=True)
|
seq |= Q(subevent__isnull=True)
|
||||||
@@ -370,10 +373,9 @@ class QuotaAvailability:
|
|||||||
Q(
|
Q(
|
||||||
Q(
|
Q(
|
||||||
Q(variation_id__isnull=True) &
|
Q(variation_id__isnull=True) &
|
||||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||||
) | Q(
|
) | Q(
|
||||||
variation_id__in={i['itemvariation_id'] for i in q_vars if
|
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
|
||||||
self._quota_objects[i['quota_id']] in quotas}
|
|
||||||
) | Q(
|
) | Q(
|
||||||
quota_id__in=[q.pk for q in quotas]
|
quota_id__in=[q.pk for q in quotas]
|
||||||
)
|
)
|
||||||
@@ -398,6 +400,7 @@ class QuotaAvailability:
|
|||||||
def _compute_carts(self, quotas, q_items, q_vars, size_left, now_dt):
|
def _compute_carts(self, quotas, q_items, q_vars, size_left, now_dt):
|
||||||
events = {q.event_id for q in quotas}
|
events = {q.event_id for q in quotas}
|
||||||
subevents = {q.subevent_id for q in quotas}
|
subevents = {q.subevent_id for q in quotas}
|
||||||
|
quota_ids = {q.pk for q in quotas}
|
||||||
seq = Q(subevent_id__in=subevents)
|
seq = Q(subevent_id__in=subevents)
|
||||||
if None in subevents:
|
if None in subevents:
|
||||||
seq |= Q(subevent__isnull=True)
|
seq |= Q(subevent__isnull=True)
|
||||||
@@ -413,9 +416,9 @@ class QuotaAvailability:
|
|||||||
Q(
|
Q(
|
||||||
Q(
|
Q(
|
||||||
Q(variation_id__isnull=True) &
|
Q(variation_id__isnull=True) &
|
||||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||||
) | Q(
|
) | Q(
|
||||||
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas}
|
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
|
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
|
||||||
@@ -434,6 +437,7 @@ class QuotaAvailability:
|
|||||||
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
|
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
|
||||||
events = {q.event_id for q in quotas}
|
events = {q.event_id for q in quotas}
|
||||||
subevents = {q.subevent_id for q in quotas}
|
subevents = {q.subevent_id for q in quotas}
|
||||||
|
quota_ids = {q.pk for q in quotas}
|
||||||
seq = Q(subevent_id__in=subevents)
|
seq = Q(subevent_id__in=subevents)
|
||||||
if None in subevents:
|
if None in subevents:
|
||||||
seq |= Q(subevent__isnull=True)
|
seq |= Q(subevent__isnull=True)
|
||||||
@@ -444,9 +448,8 @@ class QuotaAvailability:
|
|||||||
Q(
|
Q(
|
||||||
Q(
|
Q(
|
||||||
Q(variation_id__isnull=True) &
|
Q(variation_id__isnull=True) &
|
||||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||||
) | Q(variation_id__in={i['itemvariation_id'] for i in q_vars if
|
) | Q(variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids})
|
||||||
self._quota_objects[i['quota_id']] in quotas})
|
|
||||||
)
|
)
|
||||||
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
|
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
|
||||||
for line in w_lookup:
|
for line in w_lookup:
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def validate_plan_change(event, subevent, plan):
|
|||||||
seat=OuterRef('pk'),
|
seat=OuterRef('pk'),
|
||||||
canceled=False,
|
canceled=False,
|
||||||
).exclude(
|
).exclude(
|
||||||
order__status=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)
|
order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)
|
||||||
))
|
))
|
||||||
).annotate(has_v=Count('vouchers')).filter(
|
).annotate(has_v=Count('vouchers')).filter(
|
||||||
subevent=subevent,
|
subevent=subevent,
|
||||||
@@ -69,7 +69,7 @@ def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
|
|||||||
seat=OuterRef('pk'),
|
seat=OuterRef('pk'),
|
||||||
canceled=False,
|
canceled=False,
|
||||||
).exclude(
|
).exclude(
|
||||||
order__status=Order.STATUS_CANCELED
|
order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)
|
||||||
)),
|
)),
|
||||||
has_v=Count('vouchers')
|
has_v=Count('vouchers')
|
||||||
).filter(subevent=subevent).order_by():
|
).filter(subevent=subevent).order_by():
|
||||||
@@ -134,7 +134,7 @@ def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
|
|||||||
Seat.objects.bulk_create(create_seats)
|
Seat.objects.bulk_create(create_seats)
|
||||||
CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete()
|
CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete()
|
||||||
OrderPosition.all.filter(
|
OrderPosition.all.filter(
|
||||||
Q(canceled=True) | Q(order__status=Order.STATUS_CANCELED),
|
Q(canceled=True) | Q(order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)),
|
||||||
seat__in=[s.pk for s in current_seats.values()],
|
seat__in=[s.pk for s in current_seats.values()],
|
||||||
).update(seat=None)
|
).update(seat=None)
|
||||||
Seat.objects.filter(pk__in=[s.pk for s in current_seats.values()]).delete()
|
Seat.objects.filter(pk__in=[s.pk for s in current_seats.values()]).delete()
|
||||||
|
|||||||
@@ -86,14 +86,32 @@ def primary_font_kwargs():
|
|||||||
from pretix.presale.style import get_fonts
|
from pretix.presale.style import get_fonts
|
||||||
|
|
||||||
choices = [('Open Sans', 'Open Sans')]
|
choices = [('Open Sans', 'Open Sans')]
|
||||||
choices += [
|
choices += sorted([
|
||||||
(a, {"title": a, "data": v}) for a, v in get_fonts().items()
|
(a, {"title": a, "data": v}) for a, v in get_fonts().items() if not v.get('pdf_only', False)
|
||||||
]
|
], key=lambda a: a[0])
|
||||||
return {
|
return {
|
||||||
'choices': choices,
|
'choices': choices,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def restricted_plugin_kwargs():
|
||||||
|
from pretix.base.plugins import get_all_plugins
|
||||||
|
|
||||||
|
plugins_available = [
|
||||||
|
(p.module, p.name) for p in get_all_plugins(None)
|
||||||
|
if (
|
||||||
|
not p.name.startswith('.') and
|
||||||
|
getattr(p, 'restricted', False) and
|
||||||
|
not hasattr(p, 'is_available') # this means you should not really use restricted and is_available
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
'widget': forms.CheckboxSelectMultiple,
|
||||||
|
'label': _("Allow usage of restricted plugins"),
|
||||||
|
'choices': plugins_available,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class LazyI18nStringList(UserList):
|
class LazyI18nStringList(UserList):
|
||||||
def __init__(self, init_list=None):
|
def __init__(self, init_list=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -109,6 +127,13 @@ class LazyI18nStringList(UserList):
|
|||||||
|
|
||||||
|
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
|
'allowed_restricted_plugins': {
|
||||||
|
'default': [],
|
||||||
|
'type': list,
|
||||||
|
'form_class': forms.MultipleChoiceField,
|
||||||
|
'serializer_class': serializers.MultipleChoiceField,
|
||||||
|
'form_kwargs': lambda: restricted_plugin_kwargs(),
|
||||||
|
},
|
||||||
'customer_accounts': {
|
'customer_accounts': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
@@ -136,11 +161,15 @@ DEFAULTS = {
|
|||||||
'type': int,
|
'type': int,
|
||||||
'form_class': forms.IntegerField,
|
'form_class': forms.IntegerField,
|
||||||
'serializer_class': serializers.IntegerField,
|
'serializer_class': serializers.IntegerField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
min_value=1,
|
||||||
|
),
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
min_value=1,
|
min_value=1,
|
||||||
|
required=True,
|
||||||
label=_("Maximum number of items per order"),
|
label=_("Maximum number of items per order"),
|
||||||
help_text=_("Add-on products will not be counted.")
|
help_text=_("Add-on products will not be counted.")
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
'display_net_prices': {
|
'display_net_prices': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
@@ -368,11 +397,12 @@ DEFAULTS = {
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'serializer_class': I18nField,
|
'serializer_class': I18nField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Custom address field"),
|
label=_("Custom recipient field"),
|
||||||
widget=I18nTextInput,
|
widget=I18nTextInput,
|
||||||
help_text=_("If you want to add a custom text field, e.g. for a country-specific registration number, to "
|
help_text=_("If you want to add a custom text field, e.g. for a country-specific registration number, to "
|
||||||
"your invoice address form, please fill in the label here. This label will both be used for "
|
"your invoice address form, please fill in the label here. This label will both be used for "
|
||||||
"asking the user to input their details as well as for displaying the value on the invoice. "
|
"asking the user to input their details as well as for displaying the value on the invoice. It will "
|
||||||
|
"be shown on the invoice below the headline. "
|
||||||
"The field will not be required.")
|
"The field will not be required.")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -440,9 +470,11 @@ DEFAULTS = {
|
|||||||
'type': int,
|
'type': int,
|
||||||
'form_class': forms.IntegerField,
|
'form_class': forms.IntegerField,
|
||||||
'serializer_class': serializers.IntegerField,
|
'serializer_class': serializers.IntegerField,
|
||||||
|
'serializer_kwargs': dict(),
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Minimum length of invoice number after prefix"),
|
label=_("Minimum length of invoice number after prefix"),
|
||||||
help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."),
|
help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."),
|
||||||
|
required=True,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'invoice_numbers_consecutive': {
|
'invoice_numbers_consecutive': {
|
||||||
@@ -506,6 +538,7 @@ DEFAULTS = {
|
|||||||
MinValueValidator(12),
|
MinValueValidator(12),
|
||||||
MaxValueValidator(64),
|
MaxValueValidator(64),
|
||||||
],
|
],
|
||||||
|
required=True,
|
||||||
widget=forms.NumberInput(
|
widget=forms.NumberInput(
|
||||||
attrs={
|
attrs={
|
||||||
'min': '12',
|
'min': '12',
|
||||||
@@ -520,9 +553,15 @@ DEFAULTS = {
|
|||||||
'type': int,
|
'type': int,
|
||||||
'form_class': forms.IntegerField,
|
'form_class': forms.IntegerField,
|
||||||
'serializer_class': serializers.IntegerField,
|
'serializer_class': serializers.IntegerField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
min_value=0,
|
||||||
|
max_value=60 * 24 * 7,
|
||||||
|
),
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
min_value=0,
|
min_value=0,
|
||||||
|
max_value=60 * 24 * 7,
|
||||||
label=_("Reservation period"),
|
label=_("Reservation period"),
|
||||||
|
required=True,
|
||||||
help_text=_("The number of minutes the items in a user's cart are reserved for this user."),
|
help_text=_("The number of minutes the items in a user's cart are reserved for this user."),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -577,6 +616,7 @@ DEFAULTS = {
|
|||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Set payment term"),
|
label=_("Set payment term"),
|
||||||
widget=forms.RadioSelect,
|
widget=forms.RadioSelect,
|
||||||
|
required=True,
|
||||||
choices=(
|
choices=(
|
||||||
('days', _("in days")),
|
('days', _("in days")),
|
||||||
('minutes', _("in minutes"))
|
('minutes', _("in minutes"))
|
||||||
@@ -1091,9 +1131,13 @@ DEFAULTS = {
|
|||||||
'type': int,
|
'type': int,
|
||||||
'serializer_class': serializers.IntegerField,
|
'serializer_class': serializers.IntegerField,
|
||||||
'form_class': forms.IntegerField,
|
'form_class': forms.IntegerField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
min_value=1,
|
||||||
|
),
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Waiting list response time"),
|
label=_("Waiting list response time"),
|
||||||
min_value=1,
|
min_value=1,
|
||||||
|
required=True,
|
||||||
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
|
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
|
||||||
"number of hours until it expires and can be re-assigned to the next person on the list."),
|
"number of hours until it expires and can be re-assigned to the next person on the list."),
|
||||||
widget=forms.NumberInput(),
|
widget=forms.NumberInput(),
|
||||||
@@ -1153,7 +1197,20 @@ DEFAULTS = {
|
|||||||
help_text=_("If you ask for a phone number, explain why you do so and what you will use the phone number for.")
|
help_text=_("If you ask for a phone number, explain why you do so and what you will use the phone number for.")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'show_checkin_number_user': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Show number of check-ins to customer"),
|
||||||
|
help_text=_('With this option enabled, your customers will be able how many times they entered '
|
||||||
|
'the event. This is usually not necessary, but might be useful in combination with tickets '
|
||||||
|
'that are usable a specific number of times, so customers can see how many times they have '
|
||||||
|
'already been used. Exits or failed scans will not be counted, and the user will not see '
|
||||||
|
'the different check-in lists.'),
|
||||||
|
)
|
||||||
|
},
|
||||||
'ticket_download': {
|
'ticket_download': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
@@ -1512,6 +1569,17 @@ DEFAULTS = {
|
|||||||
),
|
),
|
||||||
'serializer_class': serializers.URLField,
|
'serializer_class': serializers.URLField,
|
||||||
},
|
},
|
||||||
|
'privacy_url': {
|
||||||
|
'default': None,
|
||||||
|
'type': str,
|
||||||
|
'form_class': forms.URLField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Privacy Policy URL"),
|
||||||
|
help_text=_("This should point e.g. to a part of your website that explains how you use data gathered in "
|
||||||
|
"your ticket shop."),
|
||||||
|
),
|
||||||
|
'serializer_class': serializers.URLField,
|
||||||
|
},
|
||||||
'confirm_texts': {
|
'confirm_texts': {
|
||||||
'default': LazyI18nStringList(),
|
'default': LazyI18nStringList(),
|
||||||
'type': LazyI18nStringList,
|
'type': LazyI18nStringList,
|
||||||
@@ -1545,6 +1613,32 @@ DEFAULTS = {
|
|||||||
help_text=_("If enabled, we will attach an .ics calendar file to order confirmation emails."),
|
help_text=_("If enabled, we will attach an .ics calendar file to order confirmation emails."),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'mail_attach_ical_paid_only': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Attach calendar files only after order has been paid"),
|
||||||
|
help_text=_("Use this if you e.g. put a private access link into the calendar file to make sure people only "
|
||||||
|
"receive it after their payment was confirmed."),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
'mail_attach_ical_description': {
|
||||||
|
'default': '',
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'form_class': I18nFormField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Event description"),
|
||||||
|
widget=I18nTextarea,
|
||||||
|
help_text=_(
|
||||||
|
"You can use this to share information with your attendees, such as travel information or the link to a digital event. "
|
||||||
|
"If you keep it empty, we will put a link to the event shop, the admission time, and your organizer name in there. "
|
||||||
|
"We do not allow using placeholders with sensitive person-specific data as calendar entries are often shared with an "
|
||||||
|
"unspecified number of people."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
'mail_prefix': {
|
'mail_prefix': {
|
||||||
'default': None,
|
'default': None,
|
||||||
'type': str,
|
'type': str,
|
||||||
@@ -1561,7 +1655,7 @@ DEFAULTS = {
|
|||||||
'type': str
|
'type': str
|
||||||
},
|
},
|
||||||
'mail_from': {
|
'mail_from': {
|
||||||
'default': settings.MAIL_FROM,
|
'default': settings.MAIL_FROM_ORGANIZERS,
|
||||||
'type': str,
|
'type': str,
|
||||||
'form_class': forms.EmailField,
|
'form_class': forms.EmailField,
|
||||||
'serializer_class': serializers.EmailField,
|
'serializer_class': serializers.EmailField,
|
||||||
@@ -1676,6 +1770,30 @@ You can change your order details and view the status of your order at
|
|||||||
Best regards,
|
Best regards,
|
||||||
Your {event} team"""))
|
Your {event} team"""))
|
||||||
},
|
},
|
||||||
|
'mail_attachment_new_order': {
|
||||||
|
'default': None,
|
||||||
|
'type': File,
|
||||||
|
'form_class': ExtFileField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Attachment for new orders'),
|
||||||
|
ext_whitelist=(".pdf",),
|
||||||
|
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT,
|
||||||
|
help_text=_('This file will be attached to the first email that we send for every new order. Therefore it will be '
|
||||||
|
'combined with the "Placed order", "Free order", or "Received order" texts from above. It will be sent '
|
||||||
|
'to both order contacts and attendees. You can use this e.g. to send your terms of service. Do not use '
|
||||||
|
'it to send non-public information as this file might be sent before payment is confirmed or the order '
|
||||||
|
'is approved. To avoid this vital email going to spam, you can only upload PDF files of up to {size} MB.').format(
|
||||||
|
size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT // (1024 * 1024),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'serializer_class': UploadedFileField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
allowed_types=[
|
||||||
|
'application/pdf'
|
||||||
|
],
|
||||||
|
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT,
|
||||||
|
)
|
||||||
|
},
|
||||||
'mail_send_order_placed_attendee': {
|
'mail_send_order_placed_attendee': {
|
||||||
'type': bool,
|
'type': bool,
|
||||||
'default': 'False'
|
'default': 'False'
|
||||||
@@ -1798,6 +1916,8 @@ Your {event} team"""))
|
|||||||
|
|
||||||
your order {code} for {event} has been canceled.
|
your order {code} for {event} has been canceled.
|
||||||
|
|
||||||
|
{comment}
|
||||||
|
|
||||||
You can view the details of your order at
|
You can view the details of your order at
|
||||||
{url}
|
{url}
|
||||||
|
|
||||||
@@ -2489,6 +2609,77 @@ Your {organizer} team"""))
|
|||||||
'many years. If you keep it empty, gift cards do not have an explicit expiry date.'),
|
'many years. If you keep it empty, gift cards do not have an explicit expiry date.'),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'cookie_consent': {
|
||||||
|
'default': 'False',
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Enable cookie consent management features"),
|
||||||
|
),
|
||||||
|
'type': bool,
|
||||||
|
},
|
||||||
|
'cookie_consent_dialog_text': {
|
||||||
|
'default': LazyI18nString.from_gettext(gettext_noop(
|
||||||
|
'By clicking "Accept all cookies", you agree to the storing of cookies and use of similar technologies on '
|
||||||
|
'your device.'
|
||||||
|
)),
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'serializer_class': I18nField,
|
||||||
|
'form_class': I18nFormField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Dialog text"),
|
||||||
|
widget=I18nTextarea,
|
||||||
|
widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
'cookie_consent_dialog_text_secondary': {
|
||||||
|
'default': LazyI18nString.from_gettext(gettext_noop(
|
||||||
|
'We use cookies and similar technologies to gather data that allows us to improve this website and our '
|
||||||
|
'offerings. If you do not agree, we will only use cookies if they are essential to providing the services '
|
||||||
|
'this website offers.'
|
||||||
|
)),
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'serializer_class': I18nField,
|
||||||
|
'form_class': I18nFormField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Secondary dialog text"),
|
||||||
|
widget=I18nTextarea,
|
||||||
|
widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
'cookie_consent_dialog_title': {
|
||||||
|
'default': LazyI18nString.from_gettext(gettext_noop('Privacy settings')),
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'serializer_class': I18nField,
|
||||||
|
'form_class': I18nFormField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Dialog title'),
|
||||||
|
widget=I18nTextInput,
|
||||||
|
widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
'cookie_consent_dialog_button_yes': {
|
||||||
|
'default': LazyI18nString.from_gettext(gettext_noop('Accept all cookies')),
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'serializer_class': I18nField,
|
||||||
|
'form_class': I18nFormField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('"Accept" button description'),
|
||||||
|
widget=I18nTextInput,
|
||||||
|
widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
'cookie_consent_dialog_button_no': {
|
||||||
|
'default': LazyI18nString.from_gettext(gettext_noop('Required cookies only')),
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'serializer_class': I18nField,
|
||||||
|
'form_class': I18nFormField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('"Reject" button description'),
|
||||||
|
widget=I18nTextInput,
|
||||||
|
widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}},
|
||||||
|
)
|
||||||
|
},
|
||||||
'seating_choice': {
|
'seating_choice': {
|
||||||
'default': 'True',
|
'default': 'True',
|
||||||
'form_class': forms.BooleanField,
|
'form_class': forms.BooleanField,
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import timedelta
|
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
@@ -70,11 +69,11 @@ def shred_constraints(event: Event):
|
|||||||
max_fromto=Greatest(Max('date_to'), Max('date_from'))
|
max_fromto=Greatest(Max('date_to'), Max('date_from'))
|
||||||
)
|
)
|
||||||
max_date = max_date['max_fromto'] or max_date['max_to'] or max_date['max_from']
|
max_date = max_date['max_fromto'] or max_date['max_to'] or max_date['max_from']
|
||||||
if max_date is not None and max_date > now() - timedelta(days=30):
|
if max_date is not None and max_date >= now():
|
||||||
return _('Your event needs to be over for at least 30 days to use this feature.')
|
return _('Your event needs to be over to use this feature.')
|
||||||
else:
|
else:
|
||||||
if (event.date_to or event.date_from) > now() - timedelta(days=30):
|
if (event.date_to or event.date_from) >= now():
|
||||||
return _('Your event needs to be over for at least 30 days to use this feature.')
|
return _('Your event needs to be over to use this feature.')
|
||||||
if event.live:
|
if event.live:
|
||||||
return _('Your ticket shop needs to be offline to use this feature.')
|
return _('Your ticket shop needs to be offline to use this feature.')
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -399,7 +399,10 @@ order_modified = EventPluginSignal()
|
|||||||
Arguments: ``order``
|
Arguments: ``order``
|
||||||
|
|
||||||
This signal is sent out every time an order's information is modified. The order object is given
|
This signal is sent out every time an order's information is modified. The order object is given
|
||||||
as the first argument.
|
as the first argument. In contrast to ``order_changed``, this signal is sent out if information
|
||||||
|
of an order or any of it's position is changed that concerns user input, such as attendee names,
|
||||||
|
invoice addresses or question answers. If the order changes in a material way, such as changed
|
||||||
|
products, prices, or tax rates, ``order_changed`` is used instead.
|
||||||
|
|
||||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
"""
|
"""
|
||||||
@@ -409,7 +412,10 @@ order_changed = EventPluginSignal()
|
|||||||
Arguments: ``order``
|
Arguments: ``order``
|
||||||
|
|
||||||
This signal is sent out every time an order's content is changed. The order object is given
|
This signal is sent out every time an order's content is changed. The order object is given
|
||||||
as the first argument.
|
as the first argument. In contrast to ``modified``, this signal is sent out if the order or
|
||||||
|
any of its positions changes in a material way, such as changed products, prices, or tax rates,
|
||||||
|
``order_changed`` is used instead. If "only" user input is changed, such as attendee names,
|
||||||
|
invoice addresses or question answers, ``order_modified`` is used instead.
|
||||||
|
|
||||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -85,9 +85,6 @@
|
|||||||
-webkit-hyphens: auto;
|
-webkit-hyphens: auto;
|
||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -116,6 +113,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
.content {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.content table {
|
.content table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -142,7 +142,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.order-button {
|
.order-button {
|
||||||
padding-top: 5px
|
padding-top: 5px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.order-button a.button {
|
.order-button a.button {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -173,7 +174,7 @@
|
|||||||
body {
|
body {
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
}
|
}
|
||||||
.content table td {
|
.content {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% load eventurl %}
|
{% load eventurl %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load oneline %}
|
||||||
|
|
||||||
{% if position %}
|
{% if position %}
|
||||||
<div class="order-info">
|
<div class="order-info">
|
||||||
@@ -89,13 +90,16 @@
|
|||||||
{% for groupkey, positions in cart %}
|
{% for groupkey, positions in cart %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{% if not groupkey.4 %} {# is addon #}
|
{% if not groupkey.4 %} {# is not addon #}
|
||||||
{{ positions|length }}x
|
{{ positions|length }}x
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if groupkey.4 %} {# is addon #}
|
{% if groupkey.4 %} {# is addon #}
|
||||||
+
|
+
|
||||||
|
{% if positions|length > 1 %}
|
||||||
|
{{ positions|length }}x
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ groupkey.0.name }}{% if groupkey.1 %} – {{ groupkey.1.value }}{% endif %}
|
{{ groupkey.0.name }}{% if groupkey.1 %} – {{ groupkey.1.value }}{% endif %}
|
||||||
{% if groupkey.2 %} {# subevent #}
|
{% if groupkey.2 %} {# subevent #}
|
||||||
@@ -107,6 +111,10 @@
|
|||||||
{% if event.settings.show_times %}
|
{% if event.settings.show_times %}
|
||||||
{{ groupkey.2.date_from|date:"TIME_FORMAT" }}
|
{{ groupkey.2.date_from|date:"TIME_FORMAT" }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if groupkey.2.location %}
|
||||||
|
<br>
|
||||||
|
{{ groupkey.2.location|oneline }}
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if groupkey.3 %} {# attendee name #}
|
{% if groupkey.3 %} {# attendee name #}
|
||||||
<br>
|
<br>
|
||||||
|
|||||||
@@ -104,9 +104,6 @@
|
|||||||
-webkit-hyphens: auto;
|
-webkit-hyphens: auto;
|
||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -136,6 +133,10 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.content table {
|
.content table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -197,7 +198,7 @@
|
|||||||
body {
|
body {
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
}
|
}
|
||||||
.content table td {
|
.content {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
26
src/pretix/base/templates/pretixbase/redirect.html
Normal file
26
src/pretix/base/templates/pretixbase/redirect.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "error.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load rich_text %}
|
||||||
|
{% load static %}
|
||||||
|
{% block title %}{% trans "Redirect" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<i class="fa fa-link fa-fw big-icon"></i>
|
||||||
|
<div class="error-details">
|
||||||
|
<h1>{% trans "Redirect" %}</h1>
|
||||||
|
<h3>
|
||||||
|
{% blocktrans trimmed with host="<strong>"|add:hostname|add:"</strong>"|safe %}
|
||||||
|
The link you clicked on wants to redirect you to a destination on the website {{ host }}.
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
Please only proceed if you trust this website to be safe.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
<a href="{{ url }}" class="btn btn-primary btn-lg">
|
||||||
|
{% blocktrans trimmed with host=hostname %}
|
||||||
|
Proceed to {{ host }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
31
src/pretix/base/templatetags/oneline.py
Normal file
31
src/pretix/base/templatetags/oneline.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#
|
||||||
|
# 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 django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def oneline(value):
|
||||||
|
if not value:
|
||||||
|
return ''
|
||||||
|
return ', '.join([l.strip() for l in str(value).splitlines() if l and l.strip()])
|
||||||
@@ -138,7 +138,7 @@ def truelink_callback(attrs, new=False):
|
|||||||
text = re.sub(r'[^a-zA-Z0-9.\-/_ ]', '', attrs.get('_text')) # clean up link text
|
text = re.sub(r'[^a-zA-Z0-9.\-/_ ]', '', attrs.get('_text')) # clean up link text
|
||||||
url = attrs.get((None, 'href'), '/')
|
url = attrs.get((None, 'href'), '/')
|
||||||
href_url = urllib.parse.urlparse(url)
|
href_url = urllib.parse.urlparse(url)
|
||||||
if URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
|
if (None, 'href') in attrs and URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
|
||||||
# link text looks like a url
|
# link text looks like a url
|
||||||
if text.startswith('//'):
|
if text.startswith('//'):
|
||||||
text = 'https:' + text
|
text = 'https:' + text
|
||||||
@@ -157,6 +157,8 @@ def abslink_callback(attrs, new=False):
|
|||||||
Makes sure that all links will be absolute links and will be opened in a new page with no
|
Makes sure that all links will be absolute links and will be opened in a new page with no
|
||||||
window.opener attribute.
|
window.opener attribute.
|
||||||
"""
|
"""
|
||||||
|
if (None, 'href') not in attrs:
|
||||||
|
return attrs
|
||||||
url = attrs.get((None, 'href'), '/')
|
url = attrs.get((None, 'href'), '/')
|
||||||
if not url.startswith('mailto:') and not url.startswith('tel:'):
|
if not url.startswith('mailto:') and not url.startswith('tel:'):
|
||||||
attrs[None, 'href'] = urllib.parse.urljoin(settings.SITE_URL, url)
|
attrs[None, 'href'] = urllib.parse.urljoin(settings.SITE_URL, url)
|
||||||
|
|||||||
@@ -30,67 +30,85 @@ from django.utils.translation import gettext as _
|
|||||||
from django.views.decorators.csrf import requires_csrf_token
|
from django.views.decorators.csrf import requires_csrf_token
|
||||||
from sentry_sdk import last_event_id
|
from sentry_sdk import last_event_id
|
||||||
|
|
||||||
|
from pretix.base.i18n import language
|
||||||
|
from pretix.base.middleware import get_language_from_request
|
||||||
|
|
||||||
|
|
||||||
def csrf_failure(request, reason=""):
|
def csrf_failure(request, reason=""):
|
||||||
t = get_template('csrffail.html')
|
try:
|
||||||
c = {
|
locale = get_language_from_request(request)
|
||||||
'reason': reason,
|
except:
|
||||||
'no_referer': reason == REASON_NO_REFERER,
|
locale = "en"
|
||||||
'no_referer1': _(
|
with language(locale): # Middleware might not have run, need to do this manually
|
||||||
"You are seeing this message because this HTTPS site requires a "
|
t = get_template('csrffail.html')
|
||||||
"'Referer header' to be sent by your Web browser, but none was "
|
c = {
|
||||||
"sent. This header is required for security reasons, to ensure "
|
'reason': reason,
|
||||||
"that your browser is not being hijacked by third parties."),
|
'no_referer': reason == REASON_NO_REFERER,
|
||||||
'no_referer2': _(
|
'no_referer1': _(
|
||||||
"If you have configured your browser to disable 'Referer' headers, "
|
"You are seeing this message because this HTTPS site requires a "
|
||||||
"please re-enable them, at least for this site, or for HTTPS "
|
"'Referer header' to be sent by your Web browser, but none was "
|
||||||
"connections, or for 'same-origin' requests."),
|
"sent. This header is required for security reasons, to ensure "
|
||||||
'no_cookie': reason == REASON_NO_CSRF_COOKIE,
|
"that your browser is not being hijacked by third parties."),
|
||||||
'no_cookie1': _(
|
'no_referer2': _(
|
||||||
"You are seeing this message because this site requires a CSRF "
|
"If you have configured your browser to disable 'Referer' headers, "
|
||||||
"cookie when submitting forms. This cookie is required for "
|
"please re-enable them, at least for this site, or for HTTPS "
|
||||||
"security reasons, to ensure that your browser is not being "
|
"connections, or for 'same-origin' requests."),
|
||||||
"hijacked by third parties."),
|
'no_cookie': reason == REASON_NO_CSRF_COOKIE,
|
||||||
'no_cookie2': _(
|
'no_cookie1': _(
|
||||||
"If you have configured your browser to disable cookies, please "
|
"You are seeing this message because this site requires a CSRF "
|
||||||
"re-enable them, at least for this site, or for 'same-origin' "
|
"cookie when submitting forms. This cookie is required for "
|
||||||
"requests."),
|
"security reasons, to ensure that your browser is not being "
|
||||||
}
|
"hijacked by third parties."),
|
||||||
return HttpResponseForbidden(t.render(c), content_type='text/html')
|
'no_cookie2': _(
|
||||||
|
"If you have configured your browser to disable cookies, please "
|
||||||
|
"re-enable them, at least for this site, or for 'same-origin' "
|
||||||
|
"requests."),
|
||||||
|
}
|
||||||
|
return HttpResponseForbidden(t.render(c), content_type='text/html')
|
||||||
|
|
||||||
|
|
||||||
@requires_csrf_token
|
@requires_csrf_token
|
||||||
def page_not_found(request, exception):
|
def page_not_found(request, exception):
|
||||||
exception_repr = exception.__class__.__name__
|
|
||||||
# Try to get an "interesting" exception message, if any (and not the ugly
|
|
||||||
# Resolver404 dictionary)
|
|
||||||
try:
|
try:
|
||||||
message = exception.args[0]
|
locale = get_language_from_request(request)
|
||||||
except (AttributeError, IndexError):
|
except:
|
||||||
pass
|
locale = "en"
|
||||||
else:
|
with language(locale): # Middleware might not have run, need to do this manually
|
||||||
if isinstance(message, (str, Promise)):
|
exception_repr = exception.__class__.__name__
|
||||||
exception_repr = str(message)
|
# Try to get an "interesting" exception message, if any (and not the ugly
|
||||||
context = {
|
# Resolver404 dictionary)
|
||||||
'request_path': request.path,
|
try:
|
||||||
'exception': exception_repr,
|
message = exception.args[0]
|
||||||
}
|
except (AttributeError, IndexError):
|
||||||
template = get_template('404.html')
|
pass
|
||||||
body = template.render(context, request)
|
else:
|
||||||
r = HttpResponseNotFound(body)
|
if isinstance(message, (str, Promise)):
|
||||||
r.xframe_options_exempt = True
|
exception_repr = str(message)
|
||||||
return r
|
context = {
|
||||||
|
'request_path': request.path,
|
||||||
|
'exception': exception_repr,
|
||||||
|
}
|
||||||
|
template = get_template('404.html')
|
||||||
|
body = template.render(context, request)
|
||||||
|
r = HttpResponseNotFound(body)
|
||||||
|
r.xframe_options_exempt = True
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
@requires_csrf_token
|
@requires_csrf_token
|
||||||
def server_error(request):
|
def server_error(request):
|
||||||
try:
|
try:
|
||||||
template = loader.get_template('500.html')
|
locale = get_language_from_request(request)
|
||||||
except TemplateDoesNotExist:
|
except:
|
||||||
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
locale = "en"
|
||||||
r = HttpResponseServerError(template.render({
|
with language(locale): # Middleware might not have run, need to do this manually
|
||||||
'request': request,
|
try:
|
||||||
'sentry_event_id': last_event_id(),
|
template = loader.get_template('500.html')
|
||||||
}))
|
except TemplateDoesNotExist:
|
||||||
r.xframe_options_exempt = True
|
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
||||||
return r
|
r = HttpResponseServerError(template.render({
|
||||||
|
'request': request,
|
||||||
|
'sentry_event_id': last_event_id(),
|
||||||
|
}))
|
||||||
|
r.xframe_options_exempt = True
|
||||||
|
return r
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class BaseQuestionsViewMixin:
|
|||||||
def _positions_for_questions(self):
|
def _positions_for_questions(self):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def get_question_override_sets(self, position):
|
def get_question_override_sets(self, position, index):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def question_form_kwargs(self, cr):
|
def question_form_kwargs(self, cr):
|
||||||
@@ -72,7 +72,7 @@ class BaseQuestionsViewMixin:
|
|||||||
submitted at once.
|
submitted at once.
|
||||||
"""
|
"""
|
||||||
formlist = []
|
formlist = []
|
||||||
for cr in self._positions_for_questions:
|
for idx, cr in enumerate(self._positions_for_questions):
|
||||||
cartpos = cr if isinstance(cr, CartPosition) else None
|
cartpos = cr if isinstance(cr, CartPosition) else None
|
||||||
orderpos = cr if isinstance(cr, OrderPosition) else None
|
orderpos = cr if isinstance(cr, OrderPosition) else None
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ class BaseQuestionsViewMixin:
|
|||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
|
||||||
override_sets = self.get_question_override_sets(cr)
|
override_sets = self.get_question_override_sets(cr, idx)
|
||||||
for overrides in override_sets:
|
for overrides in override_sets:
|
||||||
for question_name, question_field in form.fields.items():
|
for question_name, question_field in form.fields.items():
|
||||||
if hasattr(question_field, 'question'):
|
if hasattr(question_field, 'question'):
|
||||||
@@ -355,20 +355,29 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
|||||||
override[k].pop('initial', None)
|
override[k].pop('initial', None)
|
||||||
return override_sets
|
return override_sets
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def vat_id_validation_enabled(self):
|
||||||
|
return any([p.item.tax_rule and (p.item.tax_rule.eu_reverse_charge or p.item.tax_rule.custom_rules)
|
||||||
|
for p in self.positions])
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def invoice_form(self):
|
def invoice_form(self):
|
||||||
if not self.address_asked and self.request.event.settings.invoice_name_required:
|
if not self.address_asked and self.request.event.settings.invoice_name_required:
|
||||||
f = self.invoice_name_form_class(
|
f = self.invoice_name_form_class(
|
||||||
data=self.request.POST if self.request.method == "POST" else None,
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
event=self.request.event,
|
event=self.request.event,
|
||||||
instance=self.invoice_address, validate_vat_id=False,
|
instance=self.invoice_address,
|
||||||
|
validate_vat_id=False,
|
||||||
|
request=self.request,
|
||||||
all_optional=self.all_optional
|
all_optional=self.all_optional
|
||||||
)
|
)
|
||||||
elif self.address_asked:
|
elif self.address_asked:
|
||||||
f = self.invoice_form_class(
|
f = self.invoice_form_class(
|
||||||
data=self.request.POST if self.request.method == "POST" else None,
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
event=self.request.event,
|
event=self.request.event,
|
||||||
instance=self.invoice_address, validate_vat_id=False,
|
instance=self.invoice_address,
|
||||||
|
validate_vat_id=self.vat_id_validation_enabled,
|
||||||
|
request=self.request,
|
||||||
all_optional=self.all_optional,
|
all_optional=self.all_optional,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -23,15 +23,38 @@ import urllib.parse
|
|||||||
|
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
from django.http import HttpResponseBadRequest, HttpResponseRedirect
|
from django.http import HttpResponseBadRequest, HttpResponseRedirect
|
||||||
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
|
def _is_samesite_referer(request):
|
||||||
|
referer = request.META.get('HTTP_REFERER')
|
||||||
|
if referer is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
referer = urllib.parse.urlparse(referer)
|
||||||
|
|
||||||
|
# Make sure we have a valid URL for Referer.
|
||||||
|
if '' in (referer.scheme, referer.netloc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (referer.scheme, referer.netloc) == (request.scheme, request.get_host())
|
||||||
|
|
||||||
|
|
||||||
def redir_view(request):
|
def redir_view(request):
|
||||||
signer = signing.Signer(salt='safe-redirect')
|
signer = signing.Signer(salt='safe-redirect')
|
||||||
try:
|
try:
|
||||||
url = signer.unsign(request.GET.get('url', ''))
|
url = signer.unsign(request.GET.get('url', ''))
|
||||||
except signing.BadSignature:
|
except signing.BadSignature:
|
||||||
return HttpResponseBadRequest('Invalid parameter')
|
return HttpResponseBadRequest('Invalid parameter')
|
||||||
|
|
||||||
|
if not _is_samesite_referer(request):
|
||||||
|
u = urllib.parse.urlparse(url)
|
||||||
|
return render(request, 'pretixbase/redirect.html', {
|
||||||
|
'hostname': u.hostname,
|
||||||
|
'url': url,
|
||||||
|
})
|
||||||
|
|
||||||
r = HttpResponseRedirect(url)
|
r = HttpResponseRedirect(url)
|
||||||
r['X-Robots-Tag'] = 'noindex'
|
r['X-Robots-Tag'] = 'noindex'
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ class AsyncFormView(AsyncMixin, FormView):
|
|||||||
Also, all form keyword arguments except ``instance`` need to be serializable.
|
Also, all form keyword arguments except ``instance`` need to be serializable.
|
||||||
"""
|
"""
|
||||||
known_errortypes = ['ValidationError']
|
known_errortypes = ['ValidationError']
|
||||||
|
expected_exceptions = (ValidationError,)
|
||||||
|
task_base = ProfiledEventTask
|
||||||
|
|
||||||
def __init_subclass__(cls):
|
def __init_subclass__(cls):
|
||||||
def async_execute(self, *, request_path, query_string, form_kwargs, locale, tz, organizer=None, event=None, user=None, session_key=None):
|
def async_execute(self, *, request_path, query_string, form_kwargs, locale, tz, organizer=None, event=None, user=None, session_key=None):
|
||||||
@@ -222,7 +224,7 @@ class AsyncFormView(AsyncMixin, FormView):
|
|||||||
elif organizer:
|
elif organizer:
|
||||||
view_instance.request.organizer = organizer
|
view_instance.request.organizer = organizer
|
||||||
if user:
|
if user:
|
||||||
view_instance.request.user = User.objects.get(pk=user)
|
view_instance.request.user = User.objects.get(pk=user) if isinstance(user, int) else user
|
||||||
if session_key:
|
if session_key:
|
||||||
engine = import_module(settings.SESSION_ENGINE)
|
engine = import_module(settings.SESSION_ENGINE)
|
||||||
self.SessionStore = engine.SessionStore
|
self.SessionStore = engine.SessionStore
|
||||||
@@ -231,7 +233,7 @@ class AsyncFormView(AsyncMixin, FormView):
|
|||||||
with translation.override(locale), timezone.override(pytz.timezone(tz)):
|
with translation.override(locale), timezone.override(pytz.timezone(tz)):
|
||||||
form_class = view_instance.get_form_class()
|
form_class = view_instance.get_form_class()
|
||||||
if form_kwargs.get('instance'):
|
if form_kwargs.get('instance'):
|
||||||
cls.model.objects.get(pk=form_kwargs['instance'])
|
form_kwargs['instance'] = cls.model.objects.get(pk=form_kwargs['instance'])
|
||||||
|
|
||||||
form_kwargs = view_instance.get_async_form_kwargs(form_kwargs, organizer, event)
|
form_kwargs = view_instance.get_async_form_kwargs(form_kwargs, organizer, event)
|
||||||
form = form_class(**form_kwargs)
|
form = form_class(**form_kwargs)
|
||||||
@@ -239,10 +241,10 @@ class AsyncFormView(AsyncMixin, FormView):
|
|||||||
return view_instance.async_form_valid(self, form)
|
return view_instance.async_form_valid(self, form)
|
||||||
|
|
||||||
cls.async_execute = app.task(
|
cls.async_execute = app.task(
|
||||||
base=ProfiledEventTask,
|
base=cls.task_base,
|
||||||
bind=True,
|
bind=True,
|
||||||
name=cls.__module__ + '.' + cls.__name__ + '.async_execute',
|
name=cls.__module__ + '.' + cls.__name__ + '.async_execute',
|
||||||
throws=(ValidationError,)
|
throws=cls.expected_exceptions
|
||||||
)(async_execute)
|
)(async_execute)
|
||||||
|
|
||||||
def async_form_valid(self, task, form):
|
def async_form_valid(self, task, form):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user