mirror of
https://github.com/pretix/pretix.git
synced 2025-12-06 21:42:49 +00:00
Compare commits
658 Commits
fix-bulked
...
fix-datasy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6baf11c6d | ||
|
|
cfbe00d24d | ||
|
|
22f351cb89 | ||
|
|
2611ff74a5 | ||
|
|
cc1c7e1c23 | ||
|
|
e2eedac93b | ||
|
|
432064c3ae | ||
|
|
457115f4ca | ||
|
|
9d5563018e | ||
|
|
425f4da1f1 | ||
|
|
aa0ea27d6c | ||
|
|
5a2219124a | ||
|
|
f79813ea32 | ||
|
|
ba62db7a19 | ||
|
|
aa8b699b89 | ||
|
|
6adabd54dc | ||
|
|
e61f8035d3 | ||
|
|
fc4a9406e1 | ||
|
|
9d2ef94389 | ||
|
|
c060322f2f | ||
|
|
7629bbbb6a | ||
|
|
21d084c3fa | ||
|
|
b8d09a15e2 | ||
|
|
af2d35cc5a | ||
|
|
cbe18608e4 | ||
|
|
37d0a0de22 | ||
|
|
3bebdb3e28 | ||
|
|
4ad6a92f1d | ||
|
|
a34b6a04ea | ||
|
|
39e5711e95 | ||
|
|
6d422f9ae4 | ||
|
|
ccf4cbfd63 | ||
|
|
132a9aa9f2 | ||
|
|
257bd17b4a | ||
|
|
10bfd51d99 | ||
|
|
50225fd2f4 | ||
|
|
cd4fc1d6d8 | ||
|
|
4931059da3 | ||
|
|
15d15f978f | ||
|
|
34dbbdd82f | ||
|
|
9a54823515 | ||
|
|
4c76bb85a8 | ||
|
|
ed01a149b7 | ||
|
|
89adcc11c6 | ||
|
|
7037f348bf | ||
|
|
e694d3ca14 | ||
|
|
f3e1fd9135 | ||
|
|
850552c235 | ||
|
|
fb8a8142d9 | ||
|
|
5416c0cdfd | ||
|
|
a2421f9c66 | ||
|
|
8d06c79dd9 | ||
|
|
08961091f6 | ||
|
|
a7cbcb29b5 | ||
|
|
11fede5432 | ||
|
|
b8b89f3040 | ||
|
|
3b30553880 | ||
|
|
dd441c09f7 | ||
|
|
31b2841c4f | ||
|
|
baab35b81f | ||
|
|
c488901dc5 | ||
|
|
2679f79c3b | ||
|
|
ca3570df11 | ||
|
|
5bd08061a1 | ||
|
|
724c7d572f | ||
|
|
75dd98519f | ||
|
|
9bf4466732 | ||
|
|
ed9250c522 | ||
|
|
b3974067a5 | ||
|
|
cd6fbd886c | ||
|
|
0bb390f0a9 | ||
|
|
0183f3d40f | ||
|
|
82fcc4fe42 | ||
|
|
d42f8ece53 | ||
|
|
a8bffbd402 | ||
|
|
991b116026 | ||
|
|
2374d9b78c | ||
|
|
80785bee54 | ||
|
|
ea530ac6bf | ||
|
|
2dd8cc82f2 | ||
|
|
38fae12c37 | ||
|
|
e34a3ab2ce | ||
|
|
9401fbb1bc | ||
|
|
5d002d8b28 | ||
|
|
9b2c919026 | ||
|
|
e5ec1fd89a | ||
|
|
0f5c4b5cf5 | ||
|
|
c501066cff | ||
|
|
7ccb6682cf | ||
|
|
e5301dcdc5 | ||
|
|
4148cc4664 | ||
|
|
49057590f1 | ||
|
|
fc18659196 | ||
|
|
0c721c17e5 | ||
|
|
422567a6b7 | ||
|
|
0fcaeda0e9 | ||
|
|
ad8ed599dc | ||
|
|
4c2efa0a97 | ||
|
|
6efcd4b983 | ||
|
|
c29b7f28f1 | ||
|
|
871a8a2620 | ||
|
|
b7803565d6 | ||
|
|
f3b6627e63 | ||
|
|
574513550d | ||
|
|
f145d447a2 | ||
|
|
72b9b49b9d | ||
|
|
6d20d0e840 | ||
|
|
4a662a1aa1 | ||
|
|
8213b09847 | ||
|
|
c54f776b39 | ||
|
|
fdd03536f2 | ||
|
|
44303a0030 | ||
|
|
5ba10416ce | ||
|
|
efa117c836 | ||
|
|
70cd2265db | ||
|
|
b5afbfa1bf | ||
|
|
2dffe0e2c8 | ||
|
|
df0e0f9115 | ||
|
|
2fc47c5d71 | ||
|
|
c23d2e5504 | ||
|
|
58c7e3d316 | ||
|
|
2d5c3fbea6 | ||
|
|
222851620e | ||
|
|
9ac772b2f3 | ||
|
|
1408f31ec5 | ||
|
|
04f32284a8 | ||
|
|
318b80c3a5 | ||
|
|
102d172942 | ||
|
|
c084698821 | ||
|
|
edffe5c9dd | ||
|
|
09e9273a57 | ||
|
|
24ac588119 | ||
|
|
d23735b1a6 | ||
|
|
d8156186d8 | ||
|
|
abab7e5bc6 | ||
|
|
f89a33862a | ||
|
|
deb7cfa899 | ||
|
|
3f00fa58a0 | ||
|
|
49c0f6b967 | ||
|
|
fe9a7eaa24 | ||
|
|
ebac7d563c | ||
|
|
7ecc64ec73 | ||
|
|
c9a806a7d0 | ||
|
|
ab812a7d9c | ||
|
|
500bca1323 | ||
|
|
32be6a159e | ||
|
|
0152d0c639 | ||
|
|
e591c74862 | ||
|
|
29de29fe96 | ||
|
|
7bea17c70f | ||
|
|
f2b295e2a2 | ||
|
|
64c7bc67bd | ||
|
|
c41a754ce6 | ||
|
|
0bcb6b33bb | ||
|
|
1556226ff5 | ||
|
|
4c022cb964 | ||
|
|
8fb87fc489 | ||
|
|
c8775fb21a | ||
|
|
df0b322707 | ||
|
|
c200072471 | ||
|
|
076233cba8 | ||
|
|
b6efa9da7d | ||
|
|
489636c335 | ||
|
|
cbee131378 | ||
|
|
05c74b7ad6 | ||
|
|
37910f6037 | ||
|
|
0cc8e59bb0 | ||
|
|
7cdccc7d8e | ||
|
|
7e3f6df945 | ||
|
|
727ed67ff4 | ||
|
|
a51a6123f5 | ||
|
|
56964b6764 | ||
|
|
527bc83e5f | ||
|
|
626d7ecc90 | ||
|
|
69e50d35a7 | ||
|
|
32b704de70 | ||
|
|
1da00f575a | ||
|
|
b7d01e3b28 | ||
|
|
650b4b461f | ||
|
|
d14f7fb108 | ||
|
|
160f1c2e62 | ||
|
|
b9e627a86c | ||
|
|
328867c089 | ||
|
|
3e45274343 | ||
|
|
538ca9f0c2 | ||
|
|
99e10adad4 | ||
|
|
10b5f76356 | ||
|
|
39a0093c6b | ||
|
|
d8bf3d0b07 | ||
|
|
4e56ce8927 | ||
|
|
807df01f5d | ||
|
|
067e11c265 | ||
|
|
b4264c0ae7 | ||
|
|
61eff28978 | ||
|
|
4e89772c2d | ||
|
|
3212dd9b40 | ||
|
|
97c1fb9101 | ||
|
|
d5bccf8726 | ||
|
|
d768c46fa1 | ||
|
|
5a506bfbd6 | ||
|
|
3508d22591 | ||
|
|
4a6dd12884 | ||
|
|
60b906d8b7 | ||
|
|
4285612162 | ||
|
|
a3b1e4d208 | ||
|
|
3a6d7b8e92 | ||
|
|
a5d01aa2d1 | ||
|
|
89d8ca0fc2 | ||
|
|
34b656989f | ||
|
|
154f10af8f | ||
|
|
782d659c59 | ||
|
|
1b4308e101 | ||
|
|
9a119c35a8 | ||
|
|
a8ac1b1a94 | ||
|
|
6338dceb9e | ||
|
|
e4a171c11f | ||
|
|
e9edcfdfdc | ||
|
|
ef3ff52be3 | ||
|
|
a8f74d87ec | ||
|
|
6f920e6bcd | ||
|
|
a6201c841f | ||
|
|
b5ac28e36c | ||
|
|
bf5e1aeaff | ||
|
|
3f6d230c01 | ||
|
|
a4aa3cbd3b | ||
|
|
8ee90cd1c4 | ||
|
|
8d1e679a84 | ||
|
|
87f829f4d2 | ||
|
|
75dcb920a7 | ||
|
|
e68f0a7402 | ||
|
|
4255dbfb83 | ||
|
|
9def5cc7b2 | ||
|
|
17a467887c | ||
|
|
0736babf3c | ||
|
|
a5b773924c | ||
|
|
391918afe7 | ||
|
|
d8f9f9478d | ||
|
|
4d9f1a8efc | ||
|
|
23b07e29cd | ||
|
|
e1756a1ebb | ||
|
|
f5b0454e9f | ||
|
|
724a109c52 | ||
|
|
96df3d6831 | ||
|
|
dc164f7817 | ||
|
|
61ff0a767a | ||
|
|
423f0cbb90 | ||
|
|
200d520535 | ||
|
|
e2ae553c69 | ||
|
|
3ddf759a1b | ||
|
|
614a086227 | ||
|
|
35583f30bb | ||
|
|
38be6d13da | ||
|
|
6a8ec1ec7f | ||
|
|
0b799b132d | ||
|
|
0dd66f9468 | ||
|
|
149f1ee871 | ||
|
|
ec60ea9603 | ||
|
|
04e92e9f2f | ||
|
|
14d6013292 | ||
|
|
415bff5c72 | ||
|
|
582c6c1771 | ||
|
|
13833b05b1 | ||
|
|
a381adac33 | ||
|
|
177b9cdcbb | ||
|
|
a5f7f2bd0c | ||
|
|
6bc88b3c0d | ||
|
|
d7759f7eab | ||
|
|
1aeaa39882 | ||
|
|
1e62d06f2d | ||
|
|
a90b40035c | ||
|
|
1c79e06af8 | ||
|
|
fda8c8bc37 | ||
|
|
3f11f351b8 | ||
|
|
43cc4333a6 | ||
|
|
e1821f1bb7 | ||
|
|
4514701d1b | ||
|
|
08baf0ee32 | ||
|
|
08bbdbbd97 | ||
|
|
25cd84c459 | ||
|
|
7177ac18f7 | ||
|
|
2788ba10fe | ||
|
|
19a7042c16 | ||
|
|
14ed6982a5 | ||
|
|
090358833d | ||
|
|
f0212d910d | ||
|
|
a4c74f6310 | ||
|
|
f66a41f6a7 | ||
|
|
1a990dfecc | ||
|
|
74ac6ab102 | ||
|
|
eb912f1e22 | ||
|
|
fc7d0025ab | ||
|
|
e58e1187d0 | ||
|
|
436960ff76 | ||
|
|
e796dc3a65 | ||
|
|
545625b732 | ||
|
|
9bf302e5ae | ||
|
|
0c7c50cffc | ||
|
|
2c094f4c30 | ||
|
|
e820424bdf | ||
|
|
cb3d88a923 | ||
|
|
530ce06155 | ||
|
|
9017128513 | ||
|
|
5d3fc62ba4 | ||
|
|
243db008e1 | ||
|
|
5ea9f819e6 | ||
|
|
a5eb009e55 | ||
|
|
5129ed3846 | ||
|
|
f51906338f | ||
|
|
d67e1116f4 | ||
|
|
f6df03c427 | ||
|
|
308eac20b2 | ||
|
|
ab3c03b278 | ||
|
|
161404f152 | ||
|
|
8b119b329c | ||
|
|
512ca1966d | ||
|
|
90ec82ea1a | ||
|
|
d55f411989 | ||
|
|
40855e14d9 | ||
|
|
7bb2e4c170 | ||
|
|
dec07b2df1 | ||
|
|
9fc9aaa661 | ||
|
|
70f71c8077 | ||
|
|
dc198d4ab6 | ||
|
|
fdbb03d038 | ||
|
|
8418d03add | ||
|
|
b5f8438c18 | ||
|
|
5420f57aa2 | ||
|
|
b5e20df508 | ||
|
|
eba5c1b36d | ||
|
|
7d30ecf527 | ||
|
|
2359307462 | ||
|
|
325f7c565d | ||
|
|
df48adef1b | ||
|
|
74cea09f6c | ||
|
|
e8abe5cad8 | ||
|
|
6c9f66487d | ||
|
|
5f828127bf | ||
|
|
c5b3093f20 | ||
|
|
ae4073b3e4 | ||
|
|
362ac8de6f | ||
|
|
cced9cd768 | ||
|
|
dfb45e13ca | ||
|
|
23489f50f8 | ||
|
|
80148a8435 | ||
|
|
9f49b7747c | ||
|
|
b75f8bf893 | ||
|
|
d53af424cf | ||
|
|
24c02751cc | ||
|
|
2f7a00e660 | ||
|
|
767b01be9a | ||
|
|
f9acefc0f9 | ||
|
|
234a3d0db1 | ||
|
|
b7228ff5b8 | ||
|
|
053c713a2a | ||
|
|
6959dca7c1 | ||
|
|
87312c9d8a | ||
|
|
4b697b9244 | ||
|
|
cc55aba2e6 | ||
|
|
fbbc6502f3 | ||
|
|
62b3af2197 | ||
|
|
177717d594 | ||
|
|
2f2991105a | ||
|
|
d03af3ce06 | ||
|
|
6b95bfbc96 | ||
|
|
0f4d5b639d | ||
|
|
53ebee37fe | ||
|
|
572973b5c0 | ||
|
|
ab72abea0a | ||
|
|
c53fc8df4e | ||
|
|
87fb3d2df8 | ||
|
|
6aa3747403 | ||
|
|
d255c40a0b | ||
|
|
f600200ec6 | ||
|
|
3f9b52ad0c | ||
|
|
36c0acc574 | ||
|
|
ac2f2e073e | ||
|
|
75215b64e1 | ||
|
|
54e109251c | ||
|
|
3180bd8a6e | ||
|
|
c271c6dea8 | ||
|
|
3a48279b22 | ||
|
|
0ee451560a | ||
|
|
f0c95c4b03 | ||
|
|
5866162932 | ||
|
|
f9c0baf369 | ||
|
|
d97f203d70 | ||
|
|
0ef5385b99 | ||
|
|
66a4a34383 | ||
|
|
1752b2f037 | ||
|
|
e6dd24b9d5 | ||
|
|
2c7196d996 | ||
|
|
99e69ef4a6 | ||
|
|
03ce0d6817 | ||
|
|
509500f100 | ||
|
|
165410c2f5 | ||
|
|
875da30238 | ||
|
|
df1be0bf86 | ||
|
|
25605d294b | ||
|
|
0877da3c58 | ||
|
|
f9d1a89950 | ||
|
|
1fb29bbe85 | ||
|
|
e01b8251ce | ||
|
|
295c043375 | ||
|
|
001780e4a0 | ||
|
|
c7ca7ced6b | ||
|
|
12236cb8ed | ||
|
|
63f361f259 | ||
|
|
30e5214358 | ||
|
|
fe7a076dd4 | ||
|
|
0f4a767b58 | ||
|
|
e8ff743b76 | ||
|
|
fe0b8c9f97 | ||
|
|
8ad0944dcf | ||
|
|
92f7456eca | ||
|
|
e46e689f01 | ||
|
|
84a909b889 | ||
|
|
bacfe37686 | ||
|
|
5ec88f48da | ||
|
|
75a2418702 | ||
|
|
4c66a140e7 | ||
|
|
c10e96795f | ||
|
|
72b39932b7 | ||
|
|
5fb1fc23ce | ||
|
|
20de9830db | ||
|
|
c78c4cdef8 | ||
|
|
ba3dd5b4b6 | ||
|
|
08cbc35c72 | ||
|
|
205f2867f8 | ||
|
|
3500bdad32 | ||
|
|
52a3941864 | ||
|
|
8bc7af38b5 | ||
|
|
21fdab45ad | ||
|
|
2507db4143 | ||
|
|
a2d9b404a1 | ||
|
|
9954c07408 | ||
|
|
4895fcd7f7 | ||
|
|
4f4dda7f21 | ||
|
|
5f7a7c3953 | ||
|
|
a2d7efe7f5 | ||
|
|
9a691ccbe6 | ||
|
|
2de4377cc6 | ||
|
|
37d0414de0 | ||
|
|
2f192ab739 | ||
|
|
55278807bf | ||
|
|
988989ab20 | ||
|
|
655c504598 | ||
|
|
b304e00f48 | ||
|
|
b0d10e4b7d | ||
|
|
590acfe568 | ||
|
|
f8a5cc1bb4 | ||
|
|
7979514efd | ||
|
|
2bdad06642 | ||
|
|
5962536a11 | ||
|
|
fdbcffd5fd | ||
|
|
902527f8aa | ||
|
|
37af6edeab | ||
|
|
6e306055cb | ||
|
|
3a195b6ef9 | ||
|
|
44c5217e9e | ||
|
|
38d92bb142 | ||
|
|
465171f323 | ||
|
|
7756b6745c | ||
|
|
073c20e975 | ||
|
|
c2d5d40be6 | ||
|
|
21e5620f3f | ||
|
|
105b48829e | ||
|
|
b09f1bf5ca | ||
|
|
d616b4d648 | ||
|
|
54e222b527 | ||
|
|
6542b4f336 | ||
|
|
5659cc0cf8 | ||
|
|
4134fd8b36 | ||
|
|
a84beef269 | ||
|
|
7c59ec51ca | ||
|
|
bf47da521c | ||
|
|
e3b74249c9 | ||
|
|
1791a63f87 | ||
|
|
f931362bc5 | ||
|
|
a836dc1588 | ||
|
|
8b6685dd89 | ||
|
|
7463e41be8 | ||
|
|
31b2a9026d | ||
|
|
1d49d7cbf7 | ||
|
|
30570fe287 | ||
|
|
00508dea99 | ||
|
|
6be4e2bd7b | ||
|
|
b014446399 | ||
|
|
5053d4db6b | ||
|
|
ae2cc7a04a | ||
|
|
d49141c05d | ||
|
|
0bbb136d67 | ||
|
|
d62152beaf | ||
|
|
2ce9584a6f | ||
|
|
f1fc4cb8a4 | ||
|
|
bf3ee608ba | ||
|
|
6b331888e9 | ||
|
|
225b2452bd | ||
|
|
e7d024b146 | ||
|
|
0af94c3712 | ||
|
|
3007b89d9b | ||
|
|
9ee50a28a1 | ||
|
|
4dc5014947 | ||
|
|
ebf2039a4d | ||
|
|
f201ab8884 | ||
|
|
e20cb7649d | ||
|
|
e6cab37f12 | ||
|
|
0659338392 | ||
|
|
a0f0e0ca48 | ||
|
|
59af0bbfb8 | ||
|
|
6766d649f5 | ||
|
|
c17a090244 | ||
|
|
d103d8782b | ||
|
|
4f4903b00e | ||
|
|
caf291630c | ||
|
|
6d0368a1bb | ||
|
|
57d33e1eb1 | ||
|
|
e6ec4cb435 | ||
|
|
6043a96575 | ||
|
|
5bc1fb8e81 | ||
|
|
47c840b9e5 | ||
|
|
b6007a1af4 | ||
|
|
1caa71cdbe | ||
|
|
1f2a0278c0 | ||
|
|
cf51c879c7 | ||
|
|
1030e2dc1f | ||
|
|
8d320b24a5 | ||
|
|
8235132de8 | ||
|
|
2614f12faf | ||
|
|
6f92f2324f | ||
|
|
aaef7579d9 | ||
|
|
6b1077f881 | ||
|
|
6154b7fae0 | ||
|
|
f43be3079f | ||
|
|
3abe82ec77 | ||
|
|
7b30902963 | ||
|
|
a885c8d2e5 | ||
|
|
0ae98f072a | ||
|
|
9b1a723001 | ||
|
|
46d7799cd0 | ||
|
|
fcb67ec4b5 | ||
|
|
c7565e7c8b | ||
|
|
2316cb557a | ||
|
|
4de75f3ba5 | ||
|
|
cb972cd6ca | ||
|
|
3354ccf78a | ||
|
|
f17038101c | ||
|
|
8e343898b4 | ||
|
|
09dc504c87 | ||
|
|
d780d1d25c | ||
|
|
5522d67f9b | ||
|
|
7472564c26 | ||
|
|
2b735bec0b | ||
|
|
3e335bcbfe | ||
|
|
f676a77536 | ||
|
|
c487373340 | ||
|
|
3e05463486 | ||
|
|
31bb0f4a91 | ||
|
|
2605fe93d9 | ||
|
|
3dedfd6ee0 | ||
|
|
f71eb195c4 | ||
|
|
f09e9590a8 | ||
|
|
c53d44238c | ||
|
|
3bcc504bd8 | ||
|
|
d802f747c7 | ||
|
|
d926030bf6 | ||
|
|
1e4c577b31 | ||
|
|
8cae00941a | ||
|
|
602287b3ec | ||
|
|
817f17dac5 | ||
|
|
f33dd84900 | ||
|
|
99d0ca314d | ||
|
|
853749521e | ||
|
|
df5c6bcebf | ||
|
|
3e6051825e | ||
|
|
f4478da5ce | ||
|
|
013a065132 | ||
|
|
b3919973f1 | ||
|
|
e1027e3e8c | ||
|
|
d3792935ae | ||
|
|
1804dbebd0 | ||
|
|
7ca2f8ec04 | ||
|
|
15eb0f8870 | ||
|
|
01228dd865 | ||
|
|
bbde731dca | ||
|
|
b21ea1ba9a | ||
|
|
ef1220d240 | ||
|
|
cb3514a14f | ||
|
|
117a66a837 | ||
|
|
384d0c4824 | ||
|
|
667443ab56 | ||
|
|
22e99cf246 | ||
|
|
3e3cabe2bb | ||
|
|
ca12cbb69e | ||
|
|
21c273854c | ||
|
|
0eb11b154b | ||
|
|
8796b4359c | ||
|
|
698f687c54 | ||
|
|
ec627d6a3c | ||
|
|
a252c69988 | ||
|
|
578518e8ab | ||
|
|
d50d6a1dfd | ||
|
|
9d01072880 | ||
|
|
48c2d57cd4 | ||
|
|
2b245f727e | ||
|
|
f051ddca2d | ||
|
|
2ba2f9ff4b | ||
|
|
4ceb617104 | ||
|
|
e1d2721747 | ||
|
|
515f79b206 | ||
|
|
025ee6710c | ||
|
|
94671f6f70 | ||
|
|
c1656158f2 | ||
|
|
d1bd719f66 | ||
|
|
d61aed105f | ||
|
|
4bf4cd748c | ||
|
|
6b58c1484c | ||
|
|
6cc7a2a0de | ||
|
|
c6862454f5 | ||
|
|
6d28a7f384 | ||
|
|
15251ff208 | ||
|
|
fea519962c | ||
|
|
929a2eb6e3 | ||
|
|
08eabfa61c | ||
|
|
66dfa99e58 | ||
|
|
3b0cd35c7a | ||
|
|
810bdff5d9 | ||
|
|
54e02da2b3 | ||
|
|
86133ee52f | ||
|
|
1e3b924998 | ||
|
|
ac2b1186d1 | ||
|
|
9608c7aa15 | ||
|
|
2bb324f885 | ||
|
|
ea955c779e | ||
|
|
9844ffca98 | ||
|
|
12f4473fbd | ||
|
|
8c3ac46ddf | ||
|
|
b4d8e9ccc4 | ||
|
|
899994ef1e | ||
|
|
277a5bffa8 | ||
|
|
1bbefddc11 | ||
|
|
957462739a | ||
|
|
fa468366c7 | ||
|
|
9a61de9a22 | ||
|
|
17df83a9fb | ||
|
|
f3bd918846 | ||
|
|
5768fbca54 | ||
|
|
d88e47d76b | ||
|
|
d8ec489b13 | ||
|
|
804b048dbb | ||
|
|
a3d721c08b | ||
|
|
b5544b120d | ||
|
|
5138e86cf1 | ||
|
|
f455152447 | ||
|
|
9447e5802d | ||
|
|
e5fc7144e4 | ||
|
|
a7c8bb0f02 | ||
|
|
fbb6246020 | ||
|
|
d39a01af4d |
@@ -8,6 +8,7 @@ pretix
|
||||
:target: https://docs.pretix.eu/
|
||||
|
||||
.. image:: https://github.com/pretix/pretix/workflows/Tests/badge.svg
|
||||
:target: https://github.com/pretix/pretix/actions/workflows/tests.yml
|
||||
|
||||
.. image:: https://codecov.io/gh/pretix/pretix/branch/master/graph/badge.svg
|
||||
:target: https://codecov.io/gh/pretix/pretix
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
If you discover a vulnerability with our software or server systems, please report it to us in private. Do not to attempt to harm our users, customer's data or our system's availability when looking for vulneratbilities.
|
||||
If you discover a vulnerability with our software or server systems, please report it to us in private. Do not to attempt to harm our users, customer's data or our system's availability when looking for vulnerabilities.
|
||||
|
||||
Please contact us at security@pretix.eu with full details and steps to reproduce and allow reasonable time for us to resolve the issue before publishing your findings. If you wish to encrypt your email, you can find our GPG key [here](https://pretix.eu/.well-known/security@pretix.eu.asc).
|
||||
|
||||
We're not large enough to run a formal bug bounty program, but if you find a serious vulnerability in our service, we will find a way to show our gratitude.
|
||||
Please also see our [Responsible disclosure policy](https://docs.pretix.eu/trust/security/disclosure/).
|
||||
|
||||
## Version support
|
||||
|
||||
@@ -18,3 +18,5 @@ subscribe to our [newsletter](https://pretix.eu/about/en/blog/) in the "News abo
|
||||
category, we will also send you an email on security issues.
|
||||
|
||||
Past security issues are listed [on our website](https://pretix.eu/about/en/security).
|
||||
|
||||
Please also see our [Release cycle](https://docs.pretix.eu/trust/lifecycle/release-cycle/) documentation.
|
||||
|
||||
@@ -48,11 +48,6 @@ seat objects The assigned se
|
||||
└ seat_guid string Identifier of the seat within the seating plan
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 4.14
|
||||
|
||||
This ``is_bundled`` attribute has been added and the cart creation endpoints have been updated.
|
||||
|
||||
|
||||
Cart position endpoints
|
||||
-----------------------
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Certificate download
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/certificate/ HTTP/1.1
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/certificate/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
@@ -38,7 +38,7 @@ Certificate download
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/certificate/?result=1f550651-ae7b-4911-a76c-2be8f348aaa5 HTTP/1.1
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/certificate/?result=1f550651-ae7b-4911-a76c-2be8f348aaa5 HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
|
||||
@@ -9,14 +9,6 @@ This page describes special APIs built for ticket scanning apps. For managing ch
|
||||
please also see :ref:`rest-checkinlists`. The check-in list API also contains endpoints to obtain statistics or log
|
||||
failed scans.
|
||||
|
||||
.. versionchanged:: 4.12
|
||||
|
||||
The endpoints listed on this page have been added.
|
||||
|
||||
.. versionchanged:: 4.18
|
||||
|
||||
The ``source_type`` parameter has been added.
|
||||
|
||||
.. _`rest-checkin-redeem`:
|
||||
|
||||
Checking a ticket in
|
||||
@@ -54,6 +46,11 @@ Checking a ticket in
|
||||
this request twice with the same nonce, the second request will also succeed but will always
|
||||
create only one check-in object even when the previous request was successful as well. This
|
||||
allows for a certain level of idempotency and enables you to re-try after a connection failure.
|
||||
:<json boolean use_order_locale: Specifies that pretix should use the customer's language (``locale`` field from the
|
||||
order) when building texts (currently only the ``reason_explanation`` response field).
|
||||
Defaults to ``false`` in which case the server will determine the language (currently
|
||||
the event default language, might change in the future with support for the
|
||||
``Accept-Language`` header).
|
||||
:>json string status: ``"ok"``, ``"incomplete"``, or ``"error"``
|
||||
:>json string reason: Reason code, only set on status ``"error"``, see below for possible values.
|
||||
:>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null.
|
||||
@@ -62,7 +59,9 @@ Checking a ticket in
|
||||
will only include check-ins for the selected list. (2) An additional boolean property
|
||||
``require_attention`` will inform you whether either the order or the item have the
|
||||
``checkin_attention`` flag set. (3) If ``attendee_name`` is empty, it may automatically fall
|
||||
back to values from a parent product or from invoice addresses.
|
||||
back to values from a parent product or from invoice addresses. (4) Additional properties
|
||||
``order__status``, ``order__valid_if_pending``, ``order__require_approval``, and
|
||||
``order__locale`` are included with details form the order for convenience.
|
||||
:>json boolean require_attention: Whether or not the ``require_attention`` flag is set on the item or order.
|
||||
:>json list checkin_texts: List of additional texts to show to the user.
|
||||
:>json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found),
|
||||
@@ -360,3 +359,65 @@ Performing a ticket search
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer or check-in list does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested check-in list does not exist.
|
||||
|
||||
.. _`rest-checkin-annul`:
|
||||
|
||||
Annulment of a check-in
|
||||
-----------------------
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/checkinrpc/annul/
|
||||
|
||||
If a check-in was made in error and the person was not let in, it can be annulled. We do not recommend this to be used
|
||||
in case of manual check-ins or user interfaces because it is too prone for human errors. It is mostly intended for
|
||||
automated entry systems like a turnstile or automated door, where the check-in is first created, then the door is
|
||||
opened, and then the check-in may be annulled if the system knows that the turnstile did not turn or was out of
|
||||
order.
|
||||
|
||||
This endpoint supports passing multiple check-in lists for the context of a multi-event scan. However, each
|
||||
check-in list passed needs to be from a distinct event.
|
||||
|
||||
Check-ins created by a device can only be annulled by the same device. The datetime of annulment may not be more than
|
||||
15 minutes after the datetime of check-in (value subject to change).
|
||||
|
||||
A status code of 404 is returned if no check-in was found for the given nonce. A status code of 400 is returned when
|
||||
multiple check-ins match the nonce, the input is invalid in another way, the annulment is made from the wrong device,
|
||||
the check-in is already in an annulled or failed state, or the datetime constraint is not valid.
|
||||
|
||||
:<json string nonce: ``nonce`` value of the original check-in.
|
||||
:<json array lists: List of check-in list IDs to search on. No two check-in lists may be from the same event.
|
||||
:<json datetime datetime: Specifies the client-side datetime of the annulment. If not supplied, the current time will be used.
|
||||
:<json string error_explanation: A human-readable description of why the check-in was annulled (optional).
|
||||
:>json string status: ``"ok"``
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/checkinrpc/annul/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"lists": [1],
|
||||
"nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
|
||||
"error_explanation": "Turnstile did not turn"
|
||||
}
|
||||
|
||||
**Example successful response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "ok",
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: Invalid or incomplete request, see above
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested nonce does not exist.
|
||||
|
||||
@@ -40,10 +40,6 @@ ignore_in_statistics boolean If ``true``, ch
|
||||
consider_tickets_used boolean If ``true`` (default), tickets checked in on this list will be considered "used" by other functionality, i.e. when checking if they can still be canceled.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 4.12
|
||||
|
||||
The ``addon_match`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2023.9
|
||||
|
||||
The ``ignore_in_statistics`` and ``consider_tickets_used`` attributes have been added.
|
||||
|
||||
@@ -34,12 +34,6 @@ password string Can only be set
|
||||
not be included in any responses.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionadded:: 4.0
|
||||
|
||||
.. versionchanged:: 4.3
|
||||
|
||||
Passwords can now be set through the API during customer creation.
|
||||
|
||||
.. versionchanged:: 2024.3
|
||||
|
||||
The attribute ``phone`` has been added.
|
||||
|
||||
@@ -61,25 +61,6 @@ public_url string The public, cus
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. versionchanged:: 4.0
|
||||
|
||||
The ``clone_from`` parameter has been added to the event creation endpoint.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``with_availability_for`` parameter has been added.
|
||||
|
||||
The ``search`` query parameter has been added to filter events by their slug, name, or location in any language.
|
||||
|
||||
.. versionchanged:: 4.17
|
||||
|
||||
The ``public_url`` field has been added.
|
||||
|
||||
.. versionchanged:: 5.0
|
||||
|
||||
The ``date_from_before``, ``date_from_after``, ``date_to_before``, and ``date_to_after`` query parameters have been
|
||||
added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/
|
||||
|
||||
Returns a list of all events within a given organizer the authenticated user/token has access to.
|
||||
@@ -443,9 +424,9 @@ Endpoints
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create.
|
||||
:param event: The ``slug`` field of the event to copy settings and items from.
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The event could not be created due to invalid submitted data.
|
||||
:statuscode 400: The event could not be updated due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to update this resource.
|
||||
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/
|
||||
@@ -630,10 +611,6 @@ organizer level.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. versionchanged:: 4.18
|
||||
|
||||
The ``readonly`` flag has been added.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/settings/
|
||||
|
||||
Updates event settings. Note that ``PUT`` is not allowed here, only ``PATCH``.
|
||||
|
||||
@@ -349,6 +349,45 @@ Endpoints
|
||||
: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/(id)/vouchers/bulk_attach/
|
||||
|
||||
Attaches many **existing** vouchers to an exhibitor. You need to send either the ``id`` **or** the ``code`` field of
|
||||
the voucher, but you need to send the same field for all entries.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/vouchers/bulk_attach/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
[
|
||||
{
|
||||
"id": 15,
|
||||
"exhibitor_comment": "Free ticket"
|
||||
},
|
||||
..
|
||||
]
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{}
|
||||
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the event to use
|
||||
:param id: The ``id`` field of the exhibitor to use
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: Invalid data sent, e.g. voucher does not exist
|
||||
: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.
|
||||
|
||||
@@ -47,11 +47,6 @@ acceptor string Organizer slug
|
||||
this field was added.)
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 4.20
|
||||
|
||||
The ``owner_ticket`` and ``issuer`` attributes of the gift card and the ``info`` and ``acceptor`` attributes of the
|
||||
gift card transaction resource have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ at :ref:`plugin-docs`.
|
||||
seats
|
||||
orders
|
||||
invoices
|
||||
transactions
|
||||
vouchers
|
||||
discounts
|
||||
checkin
|
||||
@@ -54,6 +55,7 @@ at :ref:`plugin-docs`.
|
||||
digital
|
||||
exhibitors
|
||||
imported_secrets
|
||||
offlinesales
|
||||
shipping
|
||||
billing_invoices
|
||||
billing_var
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.. _rest-invoices:
|
||||
|
||||
Invoices
|
||||
========
|
||||
|
||||
@@ -24,6 +26,8 @@ invoice_from_country string Sender address:
|
||||
invoice_from_tax_id string Sender address: Local Tax ID
|
||||
invoice_from_vat_id string Sender address: EU VAT ID
|
||||
invoice_to string Full recipient address
|
||||
invoice_to_is_business boolean Recipient address: Business vs individual (``null`` for
|
||||
invoices created before pretix 2025.6).
|
||||
invoice_to_company string Recipient address: Company name
|
||||
invoice_to_name string Recipient address: Person name
|
||||
invoice_to_street string Recipient address: Address lines
|
||||
@@ -33,6 +37,7 @@ invoice_to_state string Recipient addre
|
||||
invoice_to_country string Recipient address: Country code
|
||||
invoice_to_vat_id string Recipient address: EU VAT ID
|
||||
invoice_to_beneficiary string Invoice beneficiary
|
||||
invoice_to_transmission_info object Additional transmission info (see :ref:`rest-transmission-types`)
|
||||
custom_field string Custom invoice address field
|
||||
date date Invoice date
|
||||
refers string Invoice number of an invoice this invoice refers to
|
||||
@@ -75,17 +80,12 @@ lines list of objects The actual invo
|
||||
for all invoice lines
|
||||
created before this field was introduced as well as for
|
||||
all lines not created by a fee (e.g. a product).
|
||||
├ event_date_from datetime Start date of the (sub)event this line was created for as it
|
||||
was set during invoice creation. Can be ``null`` for all invoice
|
||||
lines created before this was introduced as well as for lines in
|
||||
an event series not created by a product (e.g. shipping or
|
||||
cancellation fees).
|
||||
├ event_date_to datetime End date of the (sub)event this line was created for as it
|
||||
was set during invoice creation. Can be ``null`` for all invoice
|
||||
lines created before this was introduced as well as for lines in
|
||||
an event series not created by a product (e.g. shipping or
|
||||
cancellation fees) as well as whenever the respective (sub)event
|
||||
has no end date set.
|
||||
├ period_start datetime Start date of the service or delivery period of the invoice line.
|
||||
Can be ``null`` if not known.
|
||||
├ period_end datetime End date of the service or delivery period of the invoice line.
|
||||
Can be ``null`` if not known.
|
||||
├ event_date_from datetime Deprecated alias of ``period_start``.
|
||||
├ event_date_to datetime Deprecated alias of ``period_end``.
|
||||
├ event_location string Location of the (sub)event this line was created for as it
|
||||
was set during invoice creation. Can be ``null`` for all invoice
|
||||
lines created before this was introduced as well as for lines in
|
||||
@@ -108,21 +108,15 @@ foreign_currency_rate decimal (string) If ``foreign_cu
|
||||
foreign_currency_rate_date date If ``foreign_currency_rate`` is set, this signifies the
|
||||
date at which the currency rate was obtained.
|
||||
internal_reference string Customer's reference to be printed on the invoice.
|
||||
transmission_type string Requested transmission channel (see :ref:`rest-transmission-types`)
|
||||
transmission_provider string Selected transmission provider (depends on installed
|
||||
plugins). ``null`` if not yet chosen.
|
||||
transmission_status string Transmission status, one of ``unknown`` (pre-2025.6),
|
||||
``pending``, ``inflight``, ``failed``, and ``completed``.
|
||||
transmission_date datetime Time of last change in transmission status (may be ``null``).
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The attributes ``fee_type`` and ``fee_internal_type`` have been added.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The attribute ``lines.event_location`` has been added.
|
||||
|
||||
.. versionchanged:: 4.6
|
||||
|
||||
The attribute ``lines.subevent`` has been added.
|
||||
|
||||
.. versionchanged:: 2023.8
|
||||
|
||||
The ``event`` attribute has been added. The organizer-level endpoint has been added.
|
||||
@@ -131,6 +125,76 @@ internal_reference string Customer's refe
|
||||
|
||||
The ``tax_code`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2025.6
|
||||
|
||||
The attributes ``invoice_to_is_business``, ``invoice_to_transmission_info``, ``transmission_type``,
|
||||
``transmission_provider``, ``transmission_status``, and ``transmission_date`` have been added.
|
||||
|
||||
|
||||
.. _`rest-transmission-types`:
|
||||
|
||||
Transmission types
|
||||
------------------
|
||||
|
||||
pretix supports multiple ways to transmit an invoice from the organizer to the invoice recipient.
|
||||
For each transmission type, different fields are supported in the ``transmission_info`` object of the
|
||||
invoice address. Currently, pretix supports the following transmission types:
|
||||
|
||||
Email
|
||||
"""""
|
||||
|
||||
The identifier ``"email"`` represents the transmission of PDF invoices through email.
|
||||
This is the default transmission type in pretix and has some special behavior for backwards compatibility.
|
||||
Transmission is always executed through the provider ``"email_pdf"``.
|
||||
The ``transmission_info`` object may contain the following properties:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
transmission_email_address string Optional. An email address other than the order address
|
||||
that the invoice should be sent to.
|
||||
Business customers only.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Peppol
|
||||
""""""
|
||||
|
||||
The identifier ``"peppol"`` represents the transmission of XML invoices through the `Peppol`_ network.
|
||||
This is only available for business addresses.
|
||||
This is not supported by pretix out of the box and requires the use of a suitable plugin.
|
||||
The ``transmission_info`` object may contain the following properties:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
transmission_peppol_participant_id string Required. The Peppol participant ID of the recipient.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Italian Exchange System
|
||||
"""""""""""""""""""""""
|
||||
|
||||
The identifier ``"it_sdi"`` represents the transmission of XML invoices through the `Sistema di Interscambio`_ network used in Italy.
|
||||
This is only available for addresses with country ``"IT"``.
|
||||
This is not supported by pretix out of the box and requires the use of a suitable plugin.
|
||||
The ``transmission_info`` object may contain the following properties:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
transmission_it_sdi_codice_fiscale string Required for non-business address. Fiscal code of the
|
||||
recipient.
|
||||
transmission_it_sdi_pec string Required for business addresses. Address for certified
|
||||
electronic mail.
|
||||
transmission_it_sdi_recipient_code string Required for businesses. SdI recipient code.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
If this type is selected, ``vat_id`` is required for business addresses.
|
||||
|
||||
List of all invoices
|
||||
--------------------
|
||||
@@ -174,6 +238,7 @@ List of all invoices
|
||||
"invoice_from_vat_id":"",
|
||||
"invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT-ID: EU123456789",
|
||||
"invoice_to_company": "Sample company",
|
||||
"invoice_to_is_business": true,
|
||||
"invoice_to_name": "John Doe",
|
||||
"invoice_to_street": "Test street 12",
|
||||
"invoice_to_zipcode": "12345",
|
||||
@@ -182,6 +247,7 @@ List of all invoices
|
||||
"invoice_to_country": "TE",
|
||||
"invoice_to_vat_id": "EU123456789",
|
||||
"invoice_to_beneficiary": "",
|
||||
"invoice_to_transmission_info": {},
|
||||
"custom_field": null,
|
||||
"date": "2017-12-01",
|
||||
"refers": null,
|
||||
@@ -203,6 +269,8 @@ List of all invoices
|
||||
"fee_internal_type": null,
|
||||
"event_date_from": "2017-12-27T10:00:00Z",
|
||||
"event_date_to": null,
|
||||
"period_start": "2017-12-27T10:00:00Z",
|
||||
"period_end": "2017-12-27T10:00:00Z",
|
||||
"event_location": "Heidelberg",
|
||||
"attendee_name": null,
|
||||
"gross_value": "23.00",
|
||||
@@ -214,7 +282,11 @@ List of all invoices
|
||||
],
|
||||
"foreign_currency_display": "PLN",
|
||||
"foreign_currency_rate": "4.2408",
|
||||
"foreign_currency_rate_date": "2017-07-24"
|
||||
"foreign_currency_rate_date": "2017-07-24",
|
||||
"transmission_type": "email",
|
||||
"transmission_provider": "email_pdf",
|
||||
"transmission_status": "completed",
|
||||
"transmission_date": "2017-07-24T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -314,6 +386,7 @@ Fetching individual invoices
|
||||
"invoice_from_vat_id":"",
|
||||
"invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT-ID: EU123456789",
|
||||
"invoice_to_company": "Sample company",
|
||||
"invoice_to_is_business": true,
|
||||
"invoice_to_name": "John Doe",
|
||||
"invoice_to_street": "Test street 12",
|
||||
"invoice_to_zipcode": "12345",
|
||||
@@ -322,6 +395,7 @@ Fetching individual invoices
|
||||
"invoice_to_country": "TE",
|
||||
"invoice_to_vat_id": "EU123456789",
|
||||
"invoice_to_beneficiary": "",
|
||||
"invoice_to_transmission_info": {},
|
||||
"custom_field": null,
|
||||
"date": "2017-12-01",
|
||||
"refers": null,
|
||||
@@ -343,6 +417,8 @@ Fetching individual invoices
|
||||
"fee_internal_type": null,
|
||||
"event_date_from": "2017-12-27T10:00:00Z",
|
||||
"event_date_to": null,
|
||||
"period_start": "2017-12-27T10:00:00Z",
|
||||
"period_end": "2017-12-27T10:00:00Z",
|
||||
"event_location": "Heidelberg",
|
||||
"attendee_name": null,
|
||||
"gross_value": "23.00",
|
||||
@@ -354,7 +430,11 @@ Fetching individual invoices
|
||||
],
|
||||
"foreign_currency_display": "PLN",
|
||||
"foreign_currency_rate": "4.2408",
|
||||
"foreign_currency_rate_date": "2017-07-24"
|
||||
"foreign_currency_rate_date": "2017-07-24",
|
||||
"transmission_type": "email",
|
||||
"transmission_provider": "email_pdf",
|
||||
"transmission_status": "completed",
|
||||
"transmission_date": "2017-07-24T10:00:00Z"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -459,3 +539,70 @@ Invoices cannot be edited directly, but the following actions can be triggered:
|
||||
:statuscode 400: The invoice has already been canceled
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
|
||||
Transmitting invoices
|
||||
---------------------
|
||||
|
||||
Invoices are transmitted automatically when created during order creation or payment receipt,
|
||||
but in other cases transmission may need to be triggered manually.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/transmit/
|
||||
|
||||
Transmits the invoice to the recipient, but only if it is in ``pending`` state.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/transmit/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
Content-Type: application/pdf
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param number: The ``number`` field of the invoice to transmit
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to transmit this invoice **or** the invoice may not be transmitted
|
||||
:statuscode 409: The invoice is currently in transmission
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/retransmit/
|
||||
|
||||
Transmits the invoice to the recipient even if transmission was already attempted previously.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/retransmit/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
Content-Type: application/pdf
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param number: The ``number`` field of the invoice to transmit
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to transmit this invoice **or** the invoice may not be transmitted
|
||||
:statuscode 409: The invoice is currently in transmission
|
||||
|
||||
|
||||
.. _Peppol: https://en.wikipedia.org/wiki/PEPPOL
|
||||
.. _Sistema di Interscambio: https://it.wikipedia.org/wiki/Fattura_elettronica_in_Italia
|
||||
@@ -64,10 +64,6 @@ hide_without_voucher boolean If ``true``, th
|
||||
meta_data object Values set for event-specific meta data parameters.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 4.16
|
||||
|
||||
The ``meta_data`` and ``checkin_attention`` attributes have been added.
|
||||
|
||||
.. versionchanged:: 2023.10
|
||||
|
||||
The ``free_price_suggestion`` attribute has been added.
|
||||
|
||||
@@ -211,28 +211,6 @@ bundles list of objects Definition of
|
||||
meta_data object Values set for event-specific meta data parameters.
|
||||
======================================= ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 4.0
|
||||
|
||||
The attributes ``require_membership``, ``require_membership_types``, ``grant_membership_type``, ``grant_membership_duration_like_event``,
|
||||
``grant_membership_duration_days`` and ``grant_membership_duration_months`` have been added.
|
||||
|
||||
.. versionchanged:: 4.4
|
||||
|
||||
The attributes ``require_membership_hidden`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 4.16
|
||||
|
||||
The ``variations[x].meta_data`` and ``variations[x].checkin_attention`` attributes have been added.
|
||||
The ``personalized`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 4.17
|
||||
|
||||
The ``validity_*`` attributes have been added.
|
||||
|
||||
.. versionchanged:: 4.18
|
||||
|
||||
The ``media_policy`` and ``media_type`` attributes have been added.
|
||||
|
||||
.. versionchanged:: 2023.10
|
||||
|
||||
The ``checkin_text`` and ``variations[x].checkin_text`` attributes have been added.
|
||||
|
||||
219
doc/api/resources/offlinesales.rst
Normal file
219
doc/api/resources/offlinesales.rst
Normal file
@@ -0,0 +1,219 @@
|
||||
Offline sales
|
||||
=============
|
||||
|
||||
.. note:: This API is only available when the plugin **pretix-offlinesales** is installed (pretix Hosted and Enterprise only).
|
||||
|
||||
The offline sales module allows you to create batches of tickets intended for the sale outside the system.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The offline sales batch resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal batch ID
|
||||
creation datetime Time of creation
|
||||
testmode boolean ``true`` if orders are created in test mode
|
||||
sales_channel string Sales channel of the orders
|
||||
layout integer Internal ID of the chosen ticket layout
|
||||
subevent integer Internal ID of the chosen subevent (or ``null``)
|
||||
item integer Internal ID of the chosen product
|
||||
variation integer Internal ID of the chosen variation (or ``null``)
|
||||
amount integer Number of tickets in the batch
|
||||
comment string Internal comment
|
||||
orders list of strings List of order codes (omitted in list view for performance reasons)
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/offlinesalesbatches/
|
||||
|
||||
Returns a list of all offline sales batches
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/democon/offlinesalesbatches/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"creation": "2025-07-08T18:27:32.134368+02:00",
|
||||
"testmode": False,
|
||||
"sales_channel": "web",
|
||||
"comment": "Batch for sale at the event",
|
||||
"layout": 3,
|
||||
"subevent": null,
|
||||
"item": 23,
|
||||
"variation": null,
|
||||
"amount": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
: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 a valid event
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/offlinesalesbatches/(id)/
|
||||
|
||||
Returns information on a given batch.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/democon/offlinesalesbatches/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: text/javascript
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"creation": "2025-07-08T18:27:32.134368+02:00",
|
||||
"testmode": False,
|
||||
"sales_channel": "web",
|
||||
"comment": "Batch for sale at the event",
|
||||
"layout": 3,
|
||||
"subevent": null,
|
||||
"item": 23,
|
||||
"variation": null,
|
||||
"amount": 7,
|
||||
"orders": ["TSRNN", "3FBSL", "WMDNJ", "BHW9H", "MXSUG", "DSDAP", "URLLE"]
|
||||
}
|
||||
|
||||
: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 batch to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.
|
||||
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/offlinesalesbatches/
|
||||
|
||||
With this API call, you can instruct the system to create a new batch.
|
||||
|
||||
Since batches can contain up to 10,000 tickets, they are created asynchronously on the server.
|
||||
If your input parameters validate correctly, a ``202 Accepted`` status code is returned.
|
||||
The body points you to the check URL of the result. Running a ``GET`` request on that result URL will
|
||||
yield one of the following status codes:
|
||||
|
||||
* ``200 OK`` – The creation of the batch has succeeded. The body will be your resulting batch with the same information as in the detail endpoint above.
|
||||
* ``409 Conflict`` – Your creation job is still running. The body will be JSON with the structure ``{"status": "running"}``. ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do.
|
||||
* ``410 Gone`` – Creating the batch has failed permanently (e.g. quota no longer available). The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}``
|
||||
* ``404 Not Found`` – The job does not exist / is expired.
|
||||
|
||||
.. note:: To avoid performance issues, a maximum amount of 10000 is currently allowed.
|
||||
|
||||
.. note:: Do not wait multiple hours or more to retrieve your result. After a longer wait time, ``409`` might be returned permanently due to technical constraints, even though nothing will happen any more.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/offlinesalesbatches/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"testmode": True,
|
||||
"layout": 123,
|
||||
"item": 14,
|
||||
"sales_channel": "web",
|
||||
"amount": 10,
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"check": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/offlinesalesbatches/check/29891ede-196f-4942-9e26-d055a36e98b8/"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 202: no error
|
||||
:statuscode 400: Invalid input options
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/offlinesalesbatches/(id)/render/
|
||||
|
||||
With this API call, you can render the PDF representation of a batch.
|
||||
|
||||
Since batches can contain up to 10,000 tickets, they are rendered asynchronously on the server.
|
||||
If your input parameters validate correctly, a ``202 Accepted`` status code is returned.
|
||||
The body points you to the download URL of the result. Running a ``GET`` request on that result URL will
|
||||
yield one of the following status codes:
|
||||
|
||||
* ``200 OK`` – The creation of the batch has succeeded. The body will be your resulting batch with the same information as in the detail endpoint above.
|
||||
* ``409 Conflict`` – Your rendering process is still running. The body will be JSON with the structure ``{"status": "running"}``. ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do.
|
||||
* ``410 Gone`` – Rendering the batch has failed permanently. The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}``
|
||||
* ``404 Not Found`` – The rendering job does not exist / is expired.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/offlinesalesbatches/1/render 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
|
||||
|
||||
{
|
||||
"download": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/offlinesalesbatches/1/download/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/"
|
||||
}
|
||||
|
||||
: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 batch to fetch
|
||||
:statuscode 202: no error
|
||||
:statuscode 400: Invalid input options
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
@@ -65,11 +65,16 @@ invoice_address object Invoice address
|
||||
├ state string Customer state (ISO 3166-2 code). Only supported in
|
||||
AU, BR, CA, CN, MY, MX, and US.
|
||||
├ internal_reference string Customer's internal reference to be printed on the invoice
|
||||
|
||||
├ custom_field string Custom invoice address field
|
||||
├ vat_id string Customer VAT ID
|
||||
└ vat_id_validated string ``true``, if the VAT ID has been validated against the
|
||||
├ vat_id_validated string ``true``, if the VAT ID has been validated against the
|
||||
EU VAT service and validation was successful. This only
|
||||
happens in rare cases.
|
||||
├ transmission_type string Transmission channel for invoice (see also :ref:`rest-transmission-types`).
|
||||
Defaults to ``email``.
|
||||
└ transmission_info object Transmission-channel specific information (or ``null``).
|
||||
See also :ref:`rest-transmission-types`.
|
||||
positions list of objects List of order positions (see below). By default, only
|
||||
non-canceled positions are included.
|
||||
fees list of objects List of fees included in the order total. By default, only
|
||||
@@ -114,34 +119,6 @@ plugin_data object Additional data
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 4.0
|
||||
|
||||
The ``customer`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``custom_followup_at`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 4.4
|
||||
|
||||
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.
|
||||
|
||||
.. versionchanged:: 4.15
|
||||
|
||||
The ``include`` query parameter has been added.
|
||||
|
||||
.. versionchanged:: 4.16
|
||||
|
||||
The ``valid_if_pending`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2023.8
|
||||
|
||||
The ``event`` attribute has been added. The organizer-level endpoint has been added.
|
||||
@@ -170,6 +147,10 @@ plugin_data object Additional data
|
||||
|
||||
The ``plugin_data`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2025.6
|
||||
|
||||
The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added.
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
Order position resource
|
||||
@@ -260,10 +241,6 @@ pdf_data object Data object req
|
||||
plugin_data object Additional data added by plugins.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 4.16
|
||||
|
||||
The attributes ``blocked``, ``valid_from`` and ``valid_until`` have been added.
|
||||
|
||||
.. versionchanged:: 2024.9
|
||||
|
||||
The attribute ``print_logs`` has been added.
|
||||
@@ -400,7 +377,9 @@ List of all orders
|
||||
"state": "",
|
||||
"internal_reference": "",
|
||||
"vat_id": "EU123456789",
|
||||
"vat_id_validated": false
|
||||
"vat_id_validated": false,
|
||||
"transmission_type": "email",
|
||||
"transmission_info": {}
|
||||
},
|
||||
"positions": [
|
||||
{
|
||||
@@ -439,6 +418,7 @@ List of all orders
|
||||
"seat": null,
|
||||
"checkins": [
|
||||
{
|
||||
"id": 1337,
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
@@ -642,7 +622,9 @@ Fetching individual orders
|
||||
"state": "",
|
||||
"internal_reference": "",
|
||||
"vat_id": "EU123456789",
|
||||
"vat_id_validated": false
|
||||
"vat_id_validated": false,
|
||||
"transmission_type": "email",
|
||||
"transmission_info": {}
|
||||
},
|
||||
"positions": [
|
||||
{
|
||||
@@ -681,6 +663,7 @@ Fetching individual orders
|
||||
"seat": null,
|
||||
"checkins": [
|
||||
{
|
||||
"id": 1337,
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
@@ -756,10 +739,6 @@ Fetching individual orders
|
||||
Order ticket download
|
||||
---------------------
|
||||
|
||||
.. versionchanged:: 4.10
|
||||
|
||||
The API now supports ticket downloads for pending orders if allowed by the event settings.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/download/(output)/
|
||||
|
||||
Download tickets for an order, identified by its order code. Depending on the chosen output, the response might
|
||||
@@ -1053,6 +1032,8 @@ Creating orders
|
||||
* ``vat_id_validated`` (optional) – If you need support for reverse charge (rarely the case), you need to check
|
||||
yourself if the passed VAT ID is a valid EU VAT ID. In that case, set this to ``true``. Only valid VAT IDs will
|
||||
trigger reverse charge taxation. Don't forget to set ``is_business`` as well!
|
||||
* ``transmission_type`` (optional, defaults to ``email``)
|
||||
* ``transmission_info`` (optional, see also :ref:`rest-transmission-types`)
|
||||
|
||||
* ``positions``
|
||||
|
||||
@@ -1095,9 +1076,10 @@ Creating orders
|
||||
prices. Note that this will not include other fees and is calculated once during order generation and will not
|
||||
be respected automatically when the order changes later.)
|
||||
* ``_split_taxes_like_products`` (Optional convenience flag. If set to ``true``, your ``tax_rule`` will be ignored
|
||||
and the fee will be taxed like the products in the order. If the products have multiple tax rates, multiple fees
|
||||
will be generated with weights adjusted to the net price of the products. Note that this will be calculated once
|
||||
during order generation and is not respected automatically when the order changes later.)
|
||||
and the fee will be taxed like the products in the order *unless* the total amount of the positions is zero.
|
||||
If the products have multiple tax rates, multiple fees will be generated with weights adjusted to the net price
|
||||
of the products. Note that this will be calculated once during order generation and is not respected automatically
|
||||
when the order changes later.)
|
||||
|
||||
* ``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
|
||||
@@ -1652,6 +1634,7 @@ List of all order positions
|
||||
"blocked": null,
|
||||
"checkins": [
|
||||
{
|
||||
"id": 1337,
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
@@ -1780,6 +1763,7 @@ Fetching individual positions
|
||||
"seat": null,
|
||||
"checkins": [
|
||||
{
|
||||
"id": 1337,
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
@@ -1832,10 +1816,6 @@ Fetching individual positions
|
||||
Order position ticket download
|
||||
------------------------------
|
||||
|
||||
.. versionchanged:: 4.10
|
||||
|
||||
The API now supports ticket downloads for pending orders if allowed by the event settings.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/download/(output)/
|
||||
|
||||
Download tickets for one order position, identified by its internal ID.
|
||||
@@ -1888,15 +1868,6 @@ Order position ticket download
|
||||
Manipulating 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.
|
||||
|
||||
.. versionadded:: 4.16
|
||||
|
||||
The endpoints to manage blocks have been added.
|
||||
|
||||
.. versionchanged:: 2024.9
|
||||
|
||||
The API now supports logging ticket and badge prints.
|
||||
@@ -1943,9 +1914,14 @@ Manipulating individual positions
|
||||
|
||||
* ``valid_until``
|
||||
|
||||
* ``secret``
|
||||
|
||||
Changing parameters such as ``item`` or ``price`` will **not** automatically trigger creation of a new invoice,
|
||||
you need to take care of that yourself.
|
||||
|
||||
Changing ``secret`` does not cause a new PDF ticket to be sent to the customer, nor does it cause the old secret
|
||||
to be added to the revocation list, even if your ticket generator uses one.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -1969,6 +1945,7 @@ Manipulating individual positions
|
||||
|
||||
(Full order position resource, see above.)
|
||||
|
||||
:query boolean check_quotas: Whether to check quotas before committing item changes, default is ``true``
|
||||
:param organizer: The ``slug`` field of the organizer of the event
|
||||
:param event: The ``slug`` field of the event
|
||||
:param id: The ``id`` field of the order position to update
|
||||
@@ -2048,6 +2025,7 @@ Manipulating individual positions
|
||||
|
||||
(Full order position resource, see above.)
|
||||
|
||||
:query boolean check_quotas: Whether to check quotas before creating the new position, default is ``true``
|
||||
:param organizer: The ``slug`` field of the organizer of the event
|
||||
:param event: The ``slug`` field of the event
|
||||
|
||||
@@ -2221,10 +2199,6 @@ multiple changes to an order at once within one transaction. This makes it possi
|
||||
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:
|
||||
@@ -2338,6 +2312,7 @@ otherwise, such as splitting an order or changing fees.
|
||||
|
||||
(Full order position resource, see above.)
|
||||
|
||||
:query boolean check_quotas: Whether to check quotas before patching or creating positions, default is ``true``
|
||||
: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
|
||||
|
||||
@@ -19,16 +19,17 @@ name string The organizer's
|
||||
slug string A short form of the name, used e.g. in URLs.
|
||||
public_url string The public, customer-facing URL of the organizer, where
|
||||
the list of all events can be found (read-only).
|
||||
plugins list A list of package names of the enabled plugins for this
|
||||
organizer. Note that most plugins are enabled on the
|
||||
event level (or both levels). If you remove a plugin
|
||||
that is also enabled on some events, it will
|
||||
automatically be removed from all events as well.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. versionchanged:: 4.17
|
||||
|
||||
The ``public_url`` field has been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/
|
||||
|
||||
Returns a list of all organizers the authenticated user/token has access to.
|
||||
@@ -57,7 +58,10 @@ Endpoints
|
||||
{
|
||||
"name": "Big Events LLC",
|
||||
"slug": "Big Events",
|
||||
"public_url": "https://pretix.eu/bigevents/"
|
||||
"public_url": "https://pretix.eu/bigevents/",
|
||||
"plugins": [
|
||||
"pretix_datev"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -91,7 +95,10 @@ Endpoints
|
||||
{
|
||||
"name": "Big Events LLC",
|
||||
"slug": "Big Events",
|
||||
"public_url": "https://pretix.eu/bigevents/"
|
||||
"public_url": "https://pretix.eu/bigevents/",
|
||||
"plugins": [
|
||||
"pretix_datev"
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -99,6 +106,50 @@ Endpoints
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/
|
||||
|
||||
Updates an organizer. Currently only the ``plugins`` field may be updated.
|
||||
|
||||
Permission required: "Can change organizer settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"plugins": [
|
||||
"pretix_seating"
|
||||
]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Big Events LLC",
|
||||
"slug": "Big Events",
|
||||
"public_url": "https://pretix.eu/bigevents/",
|
||||
"plugins": [
|
||||
"pretix_seating"
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to update
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The organizer could not be updated due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to update this resource.
|
||||
|
||||
Organizer settings
|
||||
------------------
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ closed boolean Whether the quo
|
||||
field).
|
||||
release_after_exit boolean Whether the quota regains capacity as soon as some tickets
|
||||
have been scanned at an exit.
|
||||
ignore_for_event_availability boolean Whether the quota is ignored when calculating the event's
|
||||
availability of tickets.
|
||||
available boolean Whether this quota is available. Only returned if ``with_availability=true``
|
||||
is set on the request. Do not rely on this value for critical operations, it may be
|
||||
slightly out of date.
|
||||
@@ -36,10 +38,9 @@ available_number integer Number of avail
|
||||
slightly out of date. ``null`` means unlimited.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``with_availability`` query parameter has been added.
|
||||
.. versionchanged:: 2025.7
|
||||
|
||||
The attribute ``ignore_for_event_availability`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -77,7 +78,8 @@ Endpoints
|
||||
"variations": [1, 4, 5, 7],
|
||||
"subevent": null,
|
||||
"close_when_sold_out": false,
|
||||
"closed": false
|
||||
"closed": false,
|
||||
"ignore_for_event_availability": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -86,7 +88,8 @@ Endpoints
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``.
|
||||
Default: ``position``
|
||||
:query integer subevent: Only return quotas of the sub-event with the given ID.
|
||||
:query integer subevent__in: Only return quotas of sub-events with one the given IDs (comma-separated).
|
||||
:query integer subevent__in: Only return quotas of sub-events with one of the given IDs (comma-separated).
|
||||
:query integer items__in: Only return quotas that include a product with one of the given IDs (comma-separated).
|
||||
:query string with_availability: Set to ``true`` to get availability information. Can lead to increased answer times.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
@@ -122,7 +125,8 @@ Endpoints
|
||||
"variations": [1, 4, 5, 7],
|
||||
"subevent": null,
|
||||
"close_when_sold_out": false,
|
||||
"closed": false
|
||||
"closed": false,
|
||||
"ignore_for_event_availability": false
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -153,7 +157,8 @@ Endpoints
|
||||
"variations": [1, 4, 5, 7],
|
||||
"subevent": null,
|
||||
"close_when_sold_out": false,
|
||||
"closed": false
|
||||
"closed": false,
|
||||
"ignore_for_event_availability": false
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -172,7 +177,8 @@ Endpoints
|
||||
"variations": [1, 4, 5, 7],
|
||||
"subevent": null,
|
||||
"close_when_sold_out": false,
|
||||
"closed": false
|
||||
"closed": false,
|
||||
"ignore_for_event_availability": false
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event/item to create a quota for
|
||||
@@ -227,7 +233,8 @@ Endpoints
|
||||
],
|
||||
"subevent": null,
|
||||
"close_when_sold_out": false,
|
||||
"closed": false
|
||||
"closed": false,
|
||||
"ignore_for_event_availability": false
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
|
||||
@@ -6,10 +6,6 @@ Data shredders
|
||||
pretix and it's plugins include a number of data shredders that allow you to clear personal information from the system.
|
||||
This page shows you how to use these shredders through the API.
|
||||
|
||||
.. versionchanged:: 4.12
|
||||
|
||||
This feature has been added to the API.
|
||||
|
||||
.. warning::
|
||||
|
||||
Unlike the user interface, the API will not force you to download tax-relevant data before you delete it.
|
||||
|
||||
@@ -59,15 +59,6 @@ seat_category_mapping object An object mappi
|
||||
last_modified datetime Last modification of this object
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 4.15
|
||||
|
||||
The ``search`` query parameter has been added to filter sub-events by their name or location in any language.
|
||||
|
||||
.. versionchanged:: 5.0
|
||||
|
||||
The ``date_from_before``, ``date_from_after``, ``date_to_before``, and ``date_to_after`` query parameters have been
|
||||
added.
|
||||
|
||||
.. versionchanged:: 2023.8.0
|
||||
|
||||
For the organizer-wide endpoint, the ``search`` query parameter has been modified to filter sub-events by their parent events slug too.
|
||||
@@ -75,10 +66,6 @@ last_modified datetime Last modificati
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``with_availability_for`` parameter has been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/
|
||||
|
||||
Returns a list of all sub-events of an event.
|
||||
|
||||
@@ -26,6 +26,8 @@ rate decimal (string) Tax rate in per
|
||||
code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`.
|
||||
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
|
||||
the specified product price
|
||||
default boolean If ``true`` (default), this is the default tax rate for this event
|
||||
(there can only be one per event).
|
||||
eu_reverse_charge boolean **DEPRECATED**. If ``true``, EU reverse charge rules
|
||||
are applied. Will be ignored if custom rules are set.
|
||||
Use custom rules instead.
|
||||
@@ -40,10 +42,6 @@ custom_rules object Dynamic rules s
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 4.6
|
||||
|
||||
The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added.
|
||||
|
||||
.. versionchanged:: 2023.6
|
||||
|
||||
The ``custom_rules`` attribute has been added.
|
||||
@@ -52,6 +50,10 @@ custom_rules object Dynamic rules s
|
||||
|
||||
The ``code`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2025.4
|
||||
|
||||
The ``default`` attribute has been added.
|
||||
|
||||
.. _rest-taxcodes:
|
||||
|
||||
Tax codes
|
||||
@@ -115,6 +117,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "VAT"},
|
||||
"default": true,
|
||||
"internal_name": "VAT",
|
||||
"code": "S/standard",
|
||||
"rate": "19.00",
|
||||
@@ -157,6 +160,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "VAT"},
|
||||
"default": true,
|
||||
"internal_name": "VAT",
|
||||
"code": "S/standard",
|
||||
"rate": "19.00",
|
||||
@@ -207,6 +211,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "VAT"},
|
||||
"default": false,
|
||||
"internal_name": "VAT",
|
||||
"code": "S/standard",
|
||||
"rate": "19.00",
|
||||
|
||||
@@ -39,10 +39,6 @@ can_change_vouchers boolean
|
||||
can_checkin_orders boolean
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 4.18
|
||||
|
||||
The ``can_manage_reusable_media`` permission has been added.
|
||||
|
||||
Team member resource
|
||||
--------------------
|
||||
|
||||
|
||||
232
doc/api/resources/transactions.rst
Normal file
232
doc/api/resources/transactions.rst
Normal file
@@ -0,0 +1,232 @@
|
||||
.. _rest-transactions:
|
||||
|
||||
Transactions
|
||||
============
|
||||
|
||||
Transactions are an additional way to think about orders. They are are an immutable, filterable view into an order's
|
||||
history and are a good basis for financial reporting.
|
||||
|
||||
Our financial model
|
||||
-------------------
|
||||
|
||||
You can think of a pretix order similar to a debtor account in double-entry bookkeeping. For example, the flow of an
|
||||
order could look like this:
|
||||
|
||||
===================================================== ==================== =====================
|
||||
Transaction Debit Credit
|
||||
===================================================== ==================== =====================
|
||||
Order is placed with two tickets € 500
|
||||
Order is paid partially with a gift card € 200
|
||||
Remainder is paid with a credit card € 300
|
||||
One of the tickets is canceled **-** € 250
|
||||
Refund is made to the credit card **-** € 250
|
||||
**Balance** **€ 250** **€ 250**
|
||||
===================================================== ==================== =====================
|
||||
|
||||
If an order is fully settled, the sums of both columns match. However, as the movements in both columns do not always
|
||||
happen at the same time, at some times during the lifecycle of an order the sums are not balanced, in which case we
|
||||
consider an order to be "pending payment" or "overpaid".
|
||||
|
||||
In the API, the "Debit" column is represented by the "transaction" resource listed on this page.
|
||||
In many cases, the left column *usually* also matches the data returned by the :ref:`rest-invoices` resource, but there
|
||||
are two important differences:
|
||||
|
||||
- pretix may be configured such that an invoice is not always generated for an order. In this case, only the transactions
|
||||
return the full data set.
|
||||
|
||||
- pretix does not enforce a new invoice to be created e.g. when a ticket is changed to a different subevent. However,
|
||||
pretix always creates a new transaction whenever there is a change to a ticket that concerns the **price**, **tax rate**,
|
||||
**product**, or **date** (in an event series).
|
||||
|
||||
The :ref:`rest-orders` themselves are not a good representation of the "Debit" side of the table for accounting
|
||||
purposes since they are not immutable:
|
||||
They will only tell you the current state of the order, not what it was a week ago.
|
||||
|
||||
The "Credit" column is represented by the :ref:`order-payment-resource` and :ref:`order-refund-resource`.
|
||||
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the transaction
|
||||
order string Order code the transaction was created from
|
||||
event string Event slug, only present on organizer-level API calls
|
||||
created datetime The creation time of the transaction in the database
|
||||
datetime datetime The time at which the transaction is financially relevant.
|
||||
This is usually the same as created, but may vary for
|
||||
retroactively created transactions after software bugs or
|
||||
for data that preceeds this data model.
|
||||
positionid integer Number of the position within the order this refers to,
|
||||
is ``null`` for transactions that refer to a fee
|
||||
count integer Number of items purchased, is negative for cancellations
|
||||
item integer The internal ID of the item purchased (or ``null`` for fees)
|
||||
variation integer The internal ID of the variation purchased (or ``null``)
|
||||
subevent integer The internal ID of the event series date (or ``null``)
|
||||
price money (string) Gross price of the transaction
|
||||
tax_rate decimal (string) Tax rate applied in transaction
|
||||
tax_rule integer The internal ID of the tax rule used (or ``null``)
|
||||
tax_code string The selected tax code (or ``null``)
|
||||
tax_value money (string) The computed tax value
|
||||
fee_type string The type of fee (or ``null`` for products)
|
||||
internal_type string Additional type classification of the fee (or ``null`` for products)
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2025.7.0
|
||||
|
||||
This resource was added to the API.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/transactions/
|
||||
|
||||
Returns a list of all transactions of an event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/transactions/ 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": 123,
|
||||
"order": "FOO",
|
||||
"count": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"datetime": "2017-12-01T10:00:00Z",
|
||||
"item": null,
|
||||
"variation": null,
|
||||
"positionid": 1,
|
||||
"price": "23.00",
|
||||
"subevent": null,
|
||||
"tax_code": "E",
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 23,
|
||||
"tax_value": "0.00",
|
||||
"fee_type": null,
|
||||
"internal_type": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string order: Only return transactions matching the given order code.
|
||||
:query datetime_since: Only return transactions with a datetime at or after the given time.
|
||||
:query datetime_before: Only return transactions with a datetime before the given time.
|
||||
:query created_since: Only return transactions with a creation time at or after the given time.
|
||||
:query created_before: Only return transactions with a creation time before the given time.
|
||||
:query item: Only return transactions that match the given item ID.
|
||||
:query item__in: Only return transactions that match one of the given item IDs (separated with a comma).
|
||||
:query variation: Only return transactions that match the given variation ID.
|
||||
:query variation__in: Only return transactions that match one of the given variation IDs (separated with a comma).
|
||||
:query subevent: Only return transactions that match the given subevent ID.
|
||||
:query subevent__in: Only return transactions that match one of the given subevent IDs (separated with a comma).
|
||||
:query tax_rule: Only return transactions that match the given tax rule ID.
|
||||
:query tax_rule__in: Only return transactions that match one of the given tax rule IDs (separated with a comma).
|
||||
:query tax_code: Only return transactions that match the given tax code.
|
||||
:query tax_code__in: Only return transactions that match one of the given tax codes (separated with a comma).
|
||||
:query tax_rate: Only return transactions that match the given tax rate.
|
||||
:query tax_rate__in: Only return transactions that match one of the given tax rates (separated with a comma).
|
||||
:query fee_type: Only return transactions that match the given fee type.
|
||||
:query fee_type__in: Only return transactions that match one of the given fee types (separated with a comma).
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``created``, and ``id``.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of a valid event
|
||||
: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)/transactions/
|
||||
|
||||
Returns a list of all transactions of an organizer that you have access to.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/transactions/ 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": 123,
|
||||
"event": "sampleconf",
|
||||
"order": "FOO",
|
||||
"count": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"datetime": "2017-12-01T10:00:00Z",
|
||||
"item": null,
|
||||
"variation": null,
|
||||
"positionid": 1,
|
||||
"price": "23.00",
|
||||
"subevent": null,
|
||||
"tax_code": "E",
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 23,
|
||||
"tax_value": "0.00",
|
||||
"fee_type": null,
|
||||
"internal_type": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string event: Only return transactions matching the given event slug.
|
||||
:query string order: Only return transactions matching the given order code.
|
||||
:query datetime_since: Only return transactions with a datetime at or after the given time.
|
||||
:query datetime_before: Only return transactions with a datetime before the given time.
|
||||
:query created_since: Only return transactions with a creation time at or after the given time.
|
||||
:query created_before: Only return transactions with a creation time before the given time.
|
||||
:query item: Only return transactions that match the given item ID.
|
||||
:query item__in: Only return transactions that match one of the given item IDs (separated with a comma).
|
||||
:query variation: Only return transactions that match the given variation ID.
|
||||
:query variation__in: Only return transactions that match one of the given variation IDs (separated with a comma).
|
||||
:query subevent: Only return transactions that match the given subevent ID.
|
||||
:query subevent__in: Only return transactions that match one of the given subevent IDs (separated with a comma).
|
||||
:query tax_rule: Only return transactions that match the given tax rule ID.
|
||||
:query tax_rule__in: Only return transactions that match one of the given tax rule IDs (separated with a comma).
|
||||
:query tax_code: Only return transactions that match the given tax code.
|
||||
:query tax_code__in: Only return transactions that match one of the given tax codes (separated with a comma).
|
||||
:query tax_rate: Only return transactions that match the given tax rate.
|
||||
:query tax_rate__in: Only return transactions that match one of the given tax rates (separated with a comma).
|
||||
:query fee_type: Only return transactions that match the given fee type.
|
||||
:query fee_type__in: Only return transactions that match one of the given fee types (separated with a comma).
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``created``, and ``id``.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
@@ -14,6 +14,7 @@ The voucher resource contains the following public fields:
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the voucher
|
||||
created datetime The creation date of the voucher. For vouchers created before pretix 2025.7.0, this is guessed retroactively and might not be accurate.
|
||||
code string The voucher code that is required to redeem the voucher
|
||||
max_usages integer The maximum number of times this voucher can be
|
||||
redeemed (default: 1).
|
||||
@@ -49,8 +50,14 @@ subevent integer ID of the date
|
||||
show_hidden_items boolean Only if set to ``true``, this voucher allows to buy products with the property ``hide_without_voucher``. Defaults to ``true``.
|
||||
all_addons_included boolean If set to ``true``, all add-on products for the product purchased with this voucher are included in the base price.
|
||||
all_bundles_included boolean If set to ``true``, all bundled products for the product purchased with this voucher are added without their designated price.
|
||||
budget money (string) The budget a voucher is allowed to consume before being used up (or ``null``)
|
||||
budget_used money (string) The amount of budget the voucher has already used up.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2025.7
|
||||
|
||||
The attributes ``created``, ``budget``, and ``budget_used`` have been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -82,6 +89,7 @@ Endpoints
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"created": "2020-09-18T14:17:40.971519Z",
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
"max_usages": 1,
|
||||
"redeemed": 0,
|
||||
@@ -99,7 +107,9 @@ Endpoints
|
||||
"subevent": null,
|
||||
"show_hidden_items": false,
|
||||
"all_addons_included": false,
|
||||
"all_bundles_included": false
|
||||
"all_bundles_included": false,
|
||||
"budget": None,
|
||||
"budget_used": "0.00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -152,6 +162,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"created": "2020-09-18T14:17:40.971519Z",
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
"max_usages": 1,
|
||||
"redeemed": 0,
|
||||
@@ -169,7 +180,9 @@ Endpoints
|
||||
"subevent": null,
|
||||
"show_hidden_items": false,
|
||||
"all_addons_included": false,
|
||||
"all_bundles_included": false
|
||||
"all_bundles_included": false,
|
||||
"budget": None,
|
||||
"budget_used": "0.00"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -222,6 +235,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"created": "2020-09-18T14:17:40.971519Z",
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
"max_usages": 1,
|
||||
"redeemed": 0,
|
||||
@@ -239,7 +253,9 @@ Endpoints
|
||||
"subevent": null,
|
||||
"show_hidden_items": false,
|
||||
"all_addons_included": false,
|
||||
"all_bundles_included": false
|
||||
"all_bundles_included": false,
|
||||
"budget": None,
|
||||
"budget_used": "0.00"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a voucher for
|
||||
@@ -313,6 +329,7 @@ Endpoints
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"created": "2020-09-18T14:17:40.971519Z",
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
…
|
||||
}, …
|
||||
@@ -359,6 +376,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"created": "2020-09-18T14:17:40.971519Z",
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
"max_usages": 1,
|
||||
"redeemed": 0,
|
||||
@@ -376,7 +394,9 @@ Endpoints
|
||||
"subevent": null,
|
||||
"show_hidden_items": false,
|
||||
"all_addons_included": false,
|
||||
"all_bundles_included": false
|
||||
"all_bundles_included": false,
|
||||
"budget": None,
|
||||
"budget_used": "0.00"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
|
||||
@@ -60,6 +60,9 @@ The following values for ``action_types`` are valid with pretix core:
|
||||
* ``pretix.event.added``
|
||||
* ``pretix.event.changed``
|
||||
* ``pretix.event.deleted``
|
||||
* ``pretix.voucher.added``
|
||||
* ``pretix.voucher.changed``
|
||||
* ``pretix.voucher.deleted``
|
||||
* ``pretix.subevent.added``
|
||||
* ``pretix.subevent.changed``
|
||||
* ``pretix.subevent.deleted``
|
||||
|
||||
@@ -178,13 +178,6 @@ You can then implement a view as you would normally do. It will be automatically
|
||||
* Your plugin is enabled
|
||||
* The locale is set correctly
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
|
||||
The ``event_url()`` wrapper has been added in 1.7 to replace the former ``@event_view`` decorator. The
|
||||
``event_url()`` wrapper is optional and using ``url()`` still works, but you will not be able to set the
|
||||
``require_live`` setting any more via the decorator. The ``@event_view`` decorator is now deprecated and
|
||||
does nothing.
|
||||
|
||||
REST API viewsets
|
||||
-----------------
|
||||
|
||||
|
||||
207
doc/development/api/datasync.rst
Normal file
207
doc/development/api/datasync.rst
Normal file
@@ -0,0 +1,207 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
Data sync providers
|
||||
===================
|
||||
|
||||
.. warning:: This feature is considered **experimental**. It might change at any time without prior notice.
|
||||
|
||||
pretix provides connectivity to many external services through plugins. A common requirement
|
||||
is unidirectionally sending (order, customer, ticket, ...) data into external systems.
|
||||
The transfer is usually triggered by signals provided by pretix core (e.g. :data:`order_placed`),
|
||||
but performed asynchronously.
|
||||
|
||||
Such plugins should use the :class:`OutboundSyncProvider` API to utilize the queueing, retry and mapping
|
||||
mechanisms as well as the user interface for configuration and monitoring. Sync providers are registered
|
||||
in the :py:attr:`pretix.base.datasync.datasync.datasync_providers` :ref:`registry <registries>`.
|
||||
|
||||
An :class:`OutboundSyncProvider` for subscribing event participants to a mailing list could start
|
||||
like this, for example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.base.datasync.datasync import (OutboundSyncProvider, datasync_providers)
|
||||
|
||||
@datasync_providers.register
|
||||
class MyListSyncProvider(OutboundSyncProvider):
|
||||
identifier = "my_list"
|
||||
display_name = "My Mailing List Service"
|
||||
# ...
|
||||
|
||||
|
||||
The plugin must register listeners in `signals.py` for all signals that should to trigger a sync and
|
||||
within it has to call :meth:`MyListSyncProvider.enqueue_order` to enqueue the order for synchronization:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@receiver(order_placed, dispatch_uid="mylist_order_placed")
|
||||
def on_order_placed(sender, order, **kwargs):
|
||||
MyListSyncProvider.enqueue_order(order, "order_placed")
|
||||
|
||||
|
||||
Property mappings
|
||||
-----------------
|
||||
|
||||
Most of these plugins need to translate data from some pretix objects (e.g. orders)
|
||||
into an external system's data structures. Sometimes, there is only one reasonable way or the
|
||||
plugin author makes an opinionated decision what information from which objects should be
|
||||
transferred into which data structures in the external system.
|
||||
|
||||
Otherwise, you can use a :class:`PropertyMappingFormSet` to let the user set up a mapping from pretix model fields
|
||||
to external data fields. You could store the mapping information either in the event settings, or in a separate
|
||||
data model. Your implementation of :attr:`OutboundSyncProvider.mappings`
|
||||
needs to provide a list of mappings, which can be e.g. static objects or model instances, as long as they
|
||||
have at least the properties defined in
|
||||
:class:`pretix.base.datasync.datasync.StaticMapping`.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# class MyListSyncProvider, contd.
|
||||
def mappings(self):
|
||||
return [
|
||||
StaticMapping(
|
||||
id=1, pretix_model='Order', external_object_type='Contact',
|
||||
pretix_id_field='email', external_id_field='email',
|
||||
property_mappings=self.event.settings.mylist_order_mapping,
|
||||
))
|
||||
]
|
||||
|
||||
|
||||
Currently, we support `orders` and `order positions` as data sources, with the data fields defined in
|
||||
:func:`pretix.base.datasync.sourcefields.get_data_fields`.
|
||||
|
||||
To perform the actual sync, implement :func:`sync_object_with_properties` and optionally
|
||||
:func:`finalize_sync_order`. The former is called for each object to be created according to the ``mappings``.
|
||||
For each order that was enqueued using :func:`enqueue_order`:
|
||||
|
||||
- each Mapping with ``pretix_model == "Order"`` results in one call to :func:`sync_object_with_properties`,
|
||||
- each Mapping with ``pretix_model == "OrderPosition"`` results in one call to
|
||||
:func:`sync_object_with_properties` per order position,
|
||||
- :func:`finalize_sync_order` is called one time after all calls to :func:`sync_object_with_properties`.
|
||||
|
||||
|
||||
Implementation examples
|
||||
-----------------------
|
||||
|
||||
For example implementations, see the test cases in :mod:`tests.base.test_datasync`.
|
||||
|
||||
In :class:`SimpleOrderSync`, a basic data transfer of order data only is
|
||||
shown. Therein, a ``sync_object_with_properties`` method is defined as follows:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.base.datasync.utils import assign_properties
|
||||
|
||||
# class MyListSyncProvider, contd.
|
||||
def sync_object_with_properties(
|
||||
self, external_id_field, id_value, properties: list, inputs: dict,
|
||||
mapping, mapped_objects: dict, **kwargs,
|
||||
):
|
||||
# First, we query the external service if our object-to-sync already exists there.
|
||||
# This is necessary to make sure our method is idempotent, i.e. handles already synced
|
||||
# data gracefully.
|
||||
pre_existing_object = self.fake_api_client.retrieve_object(
|
||||
mapping.external_object_type,
|
||||
external_id_field,
|
||||
id_value
|
||||
)
|
||||
|
||||
# We use the helper function ``assign_properties`` to update a pre-existing object.
|
||||
update_values = assign_properties(
|
||||
new_values=properties,
|
||||
old_values=pre_existing_object or {},
|
||||
is_new=pre_existing_object is None,
|
||||
list_sep=";",
|
||||
)
|
||||
|
||||
# Then we can send our new data to the external service. The specifics of course depends
|
||||
# on your API, e.g. you may need to use different endpoints for creating or updating an
|
||||
# object, or pass the identifier separately instead of in the same dictionary as the
|
||||
# other properties.
|
||||
result = self.fake_api_client.create_or_update_object(mapping.external_object_type, {
|
||||
**update_values,
|
||||
external_id_field: id_value,
|
||||
"_id": pre_existing_object and pre_existing_object.get("_id"),
|
||||
})
|
||||
|
||||
# Finally, return a dictionary containing at least `object_type`, `external_id_field`,
|
||||
# `id_value`, `external_link_href`, and `external_link_display_name` keys.
|
||||
# Further keys may be provided for your internal use. This dictionary is provided
|
||||
# in following calls in the ``mapped_objects`` dict, to allow creating associations
|
||||
# to this object.
|
||||
return {
|
||||
"object_type": mapping.external_object_type,
|
||||
"external_id_field": external_id_field,
|
||||
"id_value": id_value,
|
||||
"external_link_href": f"https://example.org/external-system/{mapping.external_object_type}/{id_value}/",
|
||||
"external_link_display_name": f"Contact #{id_value} - Jane Doe",
|
||||
"my_result": result,
|
||||
}
|
||||
|
||||
.. note:: The result dictionaries of earlier invocations of :func:`sync_object_with_properties` are
|
||||
only provided in subsequent calls of the same sync run, such that a mapping can
|
||||
refer to e.g. the external id of an object created by a preceding mapping.
|
||||
However, the result dictionaries are currently not provided across runs. This will
|
||||
likely change in a future revision of this API, to allow easier integration of external
|
||||
systems that do not allow retrieving/updating data by a pretix-provided key.
|
||||
|
||||
``mapped_objects`` is a dictionary of lists of dictionaries. The keys to the dictionary are
|
||||
the mapping identifiers (``mapping.id``), the lists contain the result dictionaries returned
|
||||
by :func:`sync_object_with_properties`.
|
||||
|
||||
|
||||
In :class:`OrderAndTicketAssociationSync`, an example is given where orders, order positions,
|
||||
and the association between them are transferred.
|
||||
|
||||
|
||||
The OutboundSyncProvider base class
|
||||
-----------------------------------
|
||||
|
||||
.. autoclass:: pretix.base.datasync.datasync.OutboundSyncProvider
|
||||
:members:
|
||||
|
||||
|
||||
Property mapping format
|
||||
-----------------------
|
||||
|
||||
To allow the user to configure property mappings, you can use the PropertyMappingFormSet,
|
||||
which will generate the required ``property_mappings`` value automatically. If you need
|
||||
to specify the property mappings programmatically, you can refer to the description below
|
||||
on their format.
|
||||
|
||||
.. autoclass:: pretix.control.forms.mapping.PropertyMappingFormSet
|
||||
:members: to_property_mappings_json
|
||||
|
||||
A simple JSON-serialized ``property_mappings`` list for mapping some order information can look like this:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
[
|
||||
{
|
||||
"pretix_field": "email",
|
||||
"external_field": "orderemail",
|
||||
"value_map": "",
|
||||
"overwrite": "overwrite",
|
||||
},
|
||||
{
|
||||
"pretix_field": "order_status",
|
||||
"external_field": "status",
|
||||
"value_map": "{\"n\": \"pending\", \"p\": \"paid\", \"e\": \"expired\", \"c\": \"canceled\", \"r\": \"refunded\"}",
|
||||
"overwrite": "overwrite",
|
||||
},
|
||||
{
|
||||
"pretix_field": "order_total",
|
||||
"external_field": "total",
|
||||
"value_map": "",
|
||||
"overwrite": "overwrite",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Translating mappings on Event copy
|
||||
----------------------------------
|
||||
|
||||
Property mappings can contain references to event-specific primary keys. Therefore, plugins must register to the
|
||||
event_copy_data signal and call translate_property_mappings on all property mappings they store.
|
||||
|
||||
.. autofunction:: pretix.base.datasync.utils.translate_property_mappings
|
||||
@@ -23,21 +23,21 @@ There are multiple signals that will be sent out in the ordering cycle:
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:no-index:
|
||||
:members: validate_cart, validate_cart_addons, validate_order, order_valid_if_pending, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_expiry_changed, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text
|
||||
:members: validate_cart, validate_cart_addons, validate_order, order_valid_if_pending, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_expiry_changed, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, build_invoice_data, invoice_line_text
|
||||
|
||||
Check-ins
|
||||
"""""""""
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:no-index:
|
||||
:members: checkin_created
|
||||
:members: checkin_created, checkin_annulled
|
||||
|
||||
|
||||
Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, html_footer, footer_link, global_footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header, seatingframe_html_head
|
||||
:members: html_head, html_footer, footer_link, global_footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header, seatingframe_html_head, filter_subevents
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
|
||||
@@ -10,14 +10,15 @@ Contents:
|
||||
exporter
|
||||
ticketoutput
|
||||
payment
|
||||
payment_2.0
|
||||
email
|
||||
placeholder
|
||||
invoice
|
||||
invoicetransmission
|
||||
shredder
|
||||
import
|
||||
customview
|
||||
cookieconsent
|
||||
auth
|
||||
datasync
|
||||
general
|
||||
quality
|
||||
|
||||
65
doc/development/api/invoicetransmission.rst
Normal file
65
doc/development/api/invoicetransmission.rst
Normal file
@@ -0,0 +1,65 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
Writing an invoice transmission plugin
|
||||
======================================
|
||||
|
||||
An invoice transmission provider transports an invoice from the sender to the recipient.
|
||||
There are pre-defined types of invoice transmission in pretix, currently ``"email"``, ``"peppol"``, and ``"it_sdi"``.
|
||||
You can find more information about them at :ref:`rest-transmission-types`.
|
||||
|
||||
New transmission types can not be added by plugins but need to be added to pretix itself.
|
||||
However, plugins can provide implementations for the actual transmission.
|
||||
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
|
||||
|
||||
Output registration
|
||||
-------------------
|
||||
|
||||
New invoice transmission providers can be registered through the :ref:`registry <registries>` mechanism
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.base.invoicing.transmission import transmission_providers, TransmissionProvider
|
||||
|
||||
@transmission_providers.new()
|
||||
class SdiTransmissionProvider(TransmissionProvider):
|
||||
identifier = "fatturapa_providerabc"
|
||||
type = "it_sdi"
|
||||
verbose_name = _("FatturaPA through provider ABC")
|
||||
...
|
||||
|
||||
|
||||
The provider class
|
||||
------------------
|
||||
|
||||
.. class:: pretix.base.invoicing.transmission.TransmissionProvider
|
||||
|
||||
.. autoattribute:: identifier
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: type
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: verbose_name
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: priority
|
||||
|
||||
.. autoattribute:: testmode_supported
|
||||
|
||||
.. automethod:: is_ready
|
||||
|
||||
This is an abstract method, you **must** override this!
|
||||
|
||||
.. automethod:: is_available
|
||||
|
||||
This is an abstract method, you **must** override this!
|
||||
|
||||
.. automethod:: transmit
|
||||
|
||||
This is an abstract method, you **must** override this!
|
||||
|
||||
.. automethod:: settings_url
|
||||
@@ -1,129 +0,0 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
.. _`payment2.0`:
|
||||
|
||||
Porting a payment provider from pretix 1.x to pretix 2.x
|
||||
========================================================
|
||||
|
||||
In pretix 2.x, we changed large parts of the payment provider API. This documentation details the changes we made
|
||||
and shows you how you can make an existing pretix 1.x payment provider compatible with pretix 2.x
|
||||
|
||||
Conceptual overview
|
||||
-------------------
|
||||
|
||||
In pretix 1.x, an order was always directly connected to a payment provider for the full life of an order. As long as
|
||||
an order was unpaid, this could still be changed in some cases, but once an order was paid, no changes to the payment
|
||||
provider were possible any more. Additionally, the internal state of orders allowed orders only to be fully paid or
|
||||
not paid at all. This leads to a couple of consequences:
|
||||
|
||||
* Payment-related functions (like "execute payment" or "do a refund") always operated on full orders.
|
||||
|
||||
* Changing the total of an order was basically impossible once an order was paid, since there was no concept of
|
||||
partial payments or partial refunds.
|
||||
|
||||
* Payment provider plugins needed to take complicated steps to detect cases that require human intervention, like e.g.
|
||||
|
||||
* An order has expired, no quota is left to revive it, but a payment has been received
|
||||
|
||||
* A payment has been received for a canceled order
|
||||
|
||||
* A payment has been received for an order that has already been paid with a different payment method
|
||||
|
||||
* An external payment service notified us of a refund/dispute
|
||||
|
||||
We noticed that we copied and repeated large portions of code in all our official payment provider plugins, just
|
||||
to deal with some of these cases.
|
||||
|
||||
* Sometimes, there is the need to mark an order as refunded within pretix, without automatically triggering a refund
|
||||
with an external API. Every payment method needed to implement a user interface for this independently.
|
||||
|
||||
* If a refund was not possible automatically, there was no way user to track which payments actually have been refunded
|
||||
manually and which are still left to do.
|
||||
|
||||
* When the payment with one payment provider failed and the user changed to a different payment provider, all
|
||||
information about the first payment was lost from the order object and could only be retrieved from order log data,
|
||||
which also made it hard to design a data shredder API to get rid of this data.
|
||||
|
||||
In pretix 2.x, we introduced two new models, :py:class:`OrderPayment <pretix.base.models.OrderPayment>` and
|
||||
:py:class:`OrderRefund <pretix.base.models.OrderRefund>`. Each instance of these is connected to an order and
|
||||
represents one single attempt to pay or refund a specific amount of money. Each one of these has an individual state,
|
||||
can individually fail or succeed, and carries an amount variable that can differ from the order total.
|
||||
|
||||
This has the following advantages:
|
||||
|
||||
* The system can now detect orders that are over- or underpaid, independent of the payment providers in use.
|
||||
|
||||
* Therefore, we can now allow partial payments, partial refunds, and changing paid orders, and automatically detect
|
||||
the cases listed above and notify the user.
|
||||
|
||||
Payment providers now interact with those payment and refund objects more than with orders.
|
||||
|
||||
Your to-do list
|
||||
---------------
|
||||
|
||||
Payment processing
|
||||
""""""""""""""""""
|
||||
|
||||
* The method ``BasePaymentProvider.order_pending_render`` has been removed and replaced by a new
|
||||
``BasePaymentProvider.payment_pending_render(request, payment)`` method that is passed an ``OrderPayment``
|
||||
object instead of an ``Order``.
|
||||
|
||||
* The method ``BasePaymentProvider.payment_form_render`` now receives a new ``total`` parameter.
|
||||
|
||||
* The method ``BasePaymentProvider.payment_perform`` has been removed and replaced by a new method
|
||||
``BasePaymentProvider.execute_payment(request, payment)`` that is passed an ``OrderPayment``
|
||||
object instead of an ``Order``.
|
||||
|
||||
* The function ``pretix.base.services.mark_order_paid`` has been removed, instead call ``payment.confirm()``
|
||||
on a pending ``OrderPayment`` object. If no further payments are required for this order, this will also
|
||||
mark the order as paid automatically. Note that ``payment.confirm()`` can still throw a ``QuotaExceededException``,
|
||||
however it will still mark the payment as complete (not the order!), so you should catch this exception and
|
||||
inform the user, but not abort the transaction.
|
||||
|
||||
* A new property ``BasePaymentProvider.abort_pending_allowed`` has been introduced. Only if set, the user will
|
||||
be able to retry a payment or switch the payment method when the order currently has a payment object in
|
||||
state ``"pending"``. This replaces ``BasePaymentProvider.order_can_retry``, which no longer exists.
|
||||
|
||||
* The methods ``BasePaymentProvider.retry_prepare`` and ``BasePaymentProvider.order_prepare`` have both been
|
||||
replaced by a new method ``BasePaymentProvider.payment_prepare(request, payment)`` that is passed an ``OrderPayment``
|
||||
object instead of an ``Order``. **Keep in mind that this payment object might have an amount property that
|
||||
differs from the order total, if the order is already partially paid.**
|
||||
|
||||
* The method ``BasePaymentProvider.order_paid_render`` has been removed.
|
||||
|
||||
* The method ``BasePaymentProvider.order_control_render`` has been removed and replaced by a new method
|
||||
``BasePaymentProvider.payment_control_render(request, payment)`` that is passed an ``OrderPayment``
|
||||
object instead of an ``Order``.
|
||||
|
||||
* There's no need to manually deal with excess payments or duplicate payments anymore, just setting the ``OrderPayment``
|
||||
methods to the correct state will do the job.
|
||||
|
||||
Creating refunds
|
||||
""""""""""""""""
|
||||
|
||||
* The methods ``BasePaymentProvider.order_control_refund_render`` and ``BasePaymentProvider.order_control_refund_perform``
|
||||
have been removed.
|
||||
|
||||
* Two new boolean methods ``BasePaymentProvider.payment_refund_supported(payment)`` and ``BasePaymentProvider.payment_partial_refund_supported(payment)``
|
||||
have been introduced. They should be set to return ``True`` if and only if the payment API allows to *automatically*
|
||||
transfer the money back to the customer.
|
||||
|
||||
* A new method ``BasePaymentProvider.execute_refund(refund)`` has been introduced. This method is called using a
|
||||
``OrderRefund`` object in ``"created"`` state and is expected to transfer the money back and confirm success with
|
||||
calling ``refund.done()``. This will only ever be called if either ``BasePaymentProvider.payment_refund_supported(payment)``
|
||||
or ``BasePaymentProvider.payment_partial_refund_supported(payment)`` return ``True``.
|
||||
|
||||
Processing external refunds
|
||||
"""""""""""""""""""""""""""
|
||||
|
||||
* If e.g. a webhook API notifies you that a payment has been disputed or refunded with the external API, you are
|
||||
expected to call ``OrderPayment.create_external_refund(self, amount, execution_date, info='{}')`` on this payment.
|
||||
This will create and return an appropriate ``OrderRefund`` object and send out a notification. However, it will not
|
||||
mark the order as refunded, but will ask the event organizer for a decision.
|
||||
|
||||
Data shredders
|
||||
""""""""""""""
|
||||
|
||||
* The method ``BasePaymentProvider.shred_payment_info`` is no longer passed an order, but instead **either**
|
||||
an ``OrderPayment`` **or** an ``OrderRefund``.
|
||||
@@ -56,6 +56,20 @@ restricted boolean (optional) ``False`` by default, restricts a plugin
|
||||
for an event by system administrators / superusers.
|
||||
experimental boolean (optional) ``False`` by default, marks a plugin as an experimental feature in the plugins list.
|
||||
compatibility string Specifier for compatible pretix versions.
|
||||
level string System level the plugin can be activated at.
|
||||
Set to ``pretix.base.plugins.PLUGIN_LEVEL_EVENT`` for plugins that can be activated
|
||||
at event level and then be active for that event only.
|
||||
Set to ``pretix.base.plugins.PLUGIN_LEVEL_ORGANIZER`` for plugins that can be
|
||||
activated only for the organizer as a whole and are active for any event within
|
||||
that organizer.
|
||||
Set to ``pretix.base.plugins.PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID`` for plugins that
|
||||
can be activated at organizer level but are considered active only within events
|
||||
for which they have also been specifically activated.
|
||||
More levels, e.g. user-level plugins, might be invented in the future.
|
||||
settings_links list List of ``((menu name, submenu name, …), urlname, url_kwargs)`` tuples that point
|
||||
to the plugin's settings.
|
||||
navigation_links list List of ``((menu name, submenu name, …), urlname, url_kwargs)`` tuples that point
|
||||
to the plugin's system pages.
|
||||
================== ==================== ===========================================================
|
||||
|
||||
A working example would be:
|
||||
@@ -63,9 +77,9 @@ A working example would be:
|
||||
.. code-block:: python
|
||||
|
||||
try:
|
||||
from pretix.base.plugins import PluginConfig
|
||||
from pretix.base.plugins import PluginConfig, PLUGIN_LEVEL_EVENT
|
||||
except ImportError:
|
||||
raise RuntimeError("Please use pretix 2.7 or above to run this plugin!")
|
||||
raise RuntimeError("Please use pretix 2025.7 or above to run this plugin!")
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
@@ -79,6 +93,7 @@ A working example would be:
|
||||
version = '1.0.0'
|
||||
category = 'PAYMENT'
|
||||
picture = 'pretix_paypal/paypal_logo.svg'
|
||||
level = PLUGIN_LEVEL_EVENT
|
||||
visible = True
|
||||
featured = False
|
||||
restricted = False
|
||||
@@ -142,14 +157,14 @@ method to make your receivers available:
|
||||
from . import signals # NOQA
|
||||
|
||||
You can optionally specify code that is executed when your plugin is activated for an event
|
||||
in the ``installed`` method:
|
||||
or organizer in the ``installed`` method:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class PaypalApp(AppConfig):
|
||||
…
|
||||
|
||||
def installed(self, event):
|
||||
def installed(self, event_or_organizer):
|
||||
pass # Your code here
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Development setup
|
||||
|
||||
This tutorial helps you to get started hacking with pretix on your own computer. You need this to
|
||||
be able to contribute to pretix, but it might also be helpful if you want to write your own plugins.
|
||||
If you want to install pretix on a server for actual usage, go to the [administrator documentation](https://docs.pretix.eu/self-hosting/) instead.
|
||||
If you want to install pretix on a server for actual usage, go to the `administrator documentation`_ instead.
|
||||
|
||||
Obtain a copy of the source code
|
||||
--------------------------------
|
||||
@@ -221,3 +221,4 @@ your virtual environment.::
|
||||
|
||||
.. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver
|
||||
.. _pretixdroid: https://github.com/pretix/pretixdroid
|
||||
.. _administrator documentation: https://docs.pretix.eu/self-hosting/
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
.. spelling:word-list::
|
||||
|
||||
AGPL
|
||||
AGPLv3
|
||||
GPL
|
||||
LGPL
|
||||
Apache
|
||||
BSD
|
||||
MIT
|
||||
CLA
|
||||
django
|
||||
i18nfields
|
||||
hierarkey
|
||||
rami.io
|
||||
rami
|
||||
io
|
||||
GmbH
|
||||
|
||||
License FAQ
|
||||
===========
|
||||
|
||||
.. warning::
|
||||
|
||||
This FAQ tries to explain in simpler terms what the license of the pretix open source project does and does not
|
||||
allow. It is based on our interpretation of the license and is not legal advice. The contents of this page are not
|
||||
legally binding, only the original text of the license in the `license file`_ is legally binding.
|
||||
|
||||
How is pretix licensed?
|
||||
-----------------------
|
||||
|
||||
pretix follows the popular dual licensing model. It is available under the `GNU Affero General Public License 3`_ (AGPL)
|
||||
plus some additional terms, as well as under a proprietary license ("pretix Enterprise license") on request.
|
||||
|
||||
How can it be AGPL if there are additional terms?
|
||||
-------------------------------------------------
|
||||
|
||||
Even though it is fairly unknown, the AGPL's section 7 is titled "Additional Terms" and outlines specific conditions
|
||||
under which additional terms can be imposed on an AGPL-licensed work. In our case, we add three additional terms.
|
||||
|
||||
The first additional term for pretix is an additional **permission**. It allows you to do something that the AGPL would
|
||||
generally not allow. As it doesn't restrict your freedoms granted by AGPL, if you don't like it, you can ignore it, and
|
||||
if you distribute pretix further, you can remove it.
|
||||
|
||||
The second and third additional term for pretix are additional terms that restrict or specify other provisions of the
|
||||
license. AGPL specifically requires that these terms can only restrict or specify very specific things and we believe
|
||||
our additional terms are in compliance with that and are thus valid and may not be removed.
|
||||
|
||||
Why did you choose this license model?
|
||||
--------------------------------------
|
||||
|
||||
pretix was born in the open source community and we're deeply committed to building the best open source ticketing
|
||||
solution in the world. It is important to us that pretix is available with a comprehensive feature set under term that
|
||||
are compatible with the `Open Source Definition`_. This enables event organizers from all industries and regions
|
||||
to have access to a self-hosted, privacy-friendly and secure option to host their events.
|
||||
|
||||
However, developing and maintaining pretix is a lot of work. Between 2014 and 2021, we've received external
|
||||
contributions from more than 150 individuals. Not counting translations over 90 % of the development was
|
||||
done by staff engineers of rami.io GmbH, the company that started pretix. While we're very happy to receive many more
|
||||
contributions in the future, we also want to ensure that we continue to be able to pay people working on pretix
|
||||
full-time.
|
||||
|
||||
We believe our model creates a good balance between ensuring pretix is available freely as well as protecting our
|
||||
business interests. Unlike licenses chosen by other projects recently, such as the Server-Side Public License, our
|
||||
choice does not restrict using pretix for any possible use case, it just sets a few rules that you have to play by
|
||||
if you do.
|
||||
|
||||
What do I need to do if I use pretix unmodified?
|
||||
------------------------------------------------
|
||||
|
||||
If you use pretix without any modifications or plugins, you can use it for whatever you want, as long as you keep
|
||||
all copyright notices (including the link to pretix at the bottom of the site) intact.
|
||||
|
||||
You are also allowed to make copies of the unmodified source code and distribute them to others as long as you keep
|
||||
all copyright and license information intact.
|
||||
|
||||
If you install **plugins**, you must follow the same terms as when using a **modified** version (see below).
|
||||
|
||||
What do I need to do if I modify pretix?
|
||||
----------------------------------------
|
||||
|
||||
If you want to modify pretix, you have the right to do so. However, you need to follow the following rules:
|
||||
|
||||
* If you **run it for your own events** (events run by you or your company as well as companies from the same
|
||||
corporate groups) our additional permission allows you to do so **without needing to share your source code
|
||||
modifications** as long as you keep the link to pretix at the bottom of the site intact.
|
||||
|
||||
* If you **run it for others**, for example as part of a Software-as-a-Service offering or a managed hosting service
|
||||
you **must** make the source code **including all your modifications and all installed plugins** available under the
|
||||
same license as pretix to every visitor of your site. You need to do so in a prominent place such as a link at the bottom of the
|
||||
site. You also **must** keep the existing link intact.
|
||||
You **may not** add additional restrictions on the result as a whole. You **may** add additional permissions, but
|
||||
only on the parts you added. You **must** make clear which changes you made and you must not give the impression that
|
||||
your modified version is an official version of pretix.
|
||||
|
||||
* If you **distribute** the modified version, for example as a source code or software package, you **must** license it
|
||||
under the AGPL license with the same additional terms. You **may not** add additional restrictions on the result as a
|
||||
whole. You **may** add additional permissions, but only on the parts you added. You **must** make clear which changes
|
||||
you made and you must not give the impression that your modified version is an official version of pretix.
|
||||
|
||||
Does the AGPL copyleft mechanism extend to plugins?
|
||||
---------------------------------------------------
|
||||
|
||||
Yes. pretix plugins are tightly integrated with pretix, so when running pretix together with a plugin in the same
|
||||
environment they form a `combined work`_ and the copyleft mechanism of AGPL applies.
|
||||
|
||||
Can I create proprietary or secret plugins?
|
||||
-------------------------------------------
|
||||
|
||||
Yes, you can create a proprietary or secret plugin, but it may only ever be **used** in an environment that is covered
|
||||
by the additional permission from our license. As soon as the plugin is installed in an installation that is not covered
|
||||
by our additional permission (e.g. when it is used in a SaaS environment) or covered by an active pretix Enterprise
|
||||
license it **must** be released to the visitors of the site under the same license as pretix (like a modified version
|
||||
of pretix).
|
||||
|
||||
What licenses can plugins use?
|
||||
------------------------------
|
||||
|
||||
Technically, you can distribute a plugin under any free or proprietary license as long as it is distributed separately.
|
||||
However, once it is either **distributed together with pretix or used in an environment not covered by our
|
||||
additional permission** or an active pretix Enterprise license, you **must** release it to all recipients of the
|
||||
distribution or all visitors of your site under the same license as pretix (like a modified version of pretix).
|
||||
|
||||
If you release a plugin publicly, it is therefore most practical to use a license that is `compatible to AGPL`_.
|
||||
This includes most open source licenses such as AGPL, GPL, Apache, 3-clause BSD or MIT.
|
||||
|
||||
Note however that when you license a plugin with pure AGPL, it will be incompatible with our additional permission.
|
||||
Therefore, if you want to use an AGPL-licensed plugin, you'll need to publish the source code of **all** your plugins
|
||||
under AGPL terms **even if you only use it for your own events**. A plugin would add its `own additional permission`_
|
||||
to its license to allow combining it with pretix for this use case.
|
||||
|
||||
To make things less complicated, if you want to distribute a plugin freely, we therefore recommend distributing the
|
||||
plugin under **Apache License 2.0**, like we do for most plugins we distribute as open source.
|
||||
|
||||
What do I need to do if I want to contribute my changes back?
|
||||
-------------------------------------------------------------
|
||||
|
||||
In order to retain the possibility for us to offer pretix in a dual licensing model, we unfortunately need you to sign
|
||||
a Contributor License Agreement (CLA) that gives us permission to use your contribution in all present and future
|
||||
distributions of pretix. We know the bureaucracy sucks. Sorry.
|
||||
|
||||
What if I want to re-use a minor part of pretix in my project?
|
||||
--------------------------------------------------------------
|
||||
|
||||
This is the main part we dislike about AGPL: If you see a specific thing in pretix that you'd like to use in another
|
||||
project, you'll need to distribute your other project under AGPL terms as well which is often not practical.
|
||||
|
||||
In this case, feel free to get in touch with us! We're happy to grant you special permission or pull the component
|
||||
out into a separately, permissively licensed repository. We already did that with `django-hierarkey`_ and
|
||||
`django-i18nfield`_ which have previously been parts of pretix.
|
||||
|
||||
What can I use the name "pretix" for?
|
||||
-------------------------------------
|
||||
|
||||
The name pretix is a registered trademark by rami.io GmbH.
|
||||
|
||||
* You **may** use it to **indicate copyright**, such as in the "powered by pretix" or "based on pretix" line, or when
|
||||
indicating that a distribution is based on pretix.
|
||||
|
||||
* You **may** use it to **indicate compatibility**, for example you are allowed to name your plugin "<name> for pretix"
|
||||
or you may state that an external service is compatible with pretix.
|
||||
|
||||
* You **may not** give the impression that your modified version, plugin or compatible service is official or authorized
|
||||
by rami.io GmbH or pretix unless we specifically allowed you to do so.
|
||||
|
||||
* You **may not** use it to name your modified version of pretix. End-users must be able to easily identify whether
|
||||
a version of pretix is distributed by us.
|
||||
|
||||
* You **may not** use any variations of the name, such as "MyPretix".
|
||||
|
||||
.. _license file: https://github.com/pretix/pretix/blob/master/LICENSE
|
||||
.. _GNU Affero General Public License 3: https://www.gnu.org/licenses/agpl-3.0.en.html
|
||||
.. _compatible to AGPL: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses
|
||||
.. _Open Source Definition: https://opensource.org/osd
|
||||
.. _combined work: https://www.gnu.org/licenses/gpl-faq.html#GPLPlugins
|
||||
.. _own additional permission: https://www.gnu.org/licenses/gpl-faq.html#GPLIncompatibleLibs
|
||||
.. _django-hierarkey: https://github.com/raphaelm/django-hierarkey
|
||||
.. _django-i18nfield: https://github.com/raphaelm/django-i18nfield
|
||||
@@ -6,4 +6,4 @@ sphinxcontrib-images
|
||||
sphinxcontrib-jquery
|
||||
sphinxcontrib-spelling==8.*
|
||||
sphinxemoji
|
||||
pyenchant==3.2.*
|
||||
pyenchant==3.3.*
|
||||
|
||||
@@ -7,4 +7,4 @@ sphinxcontrib-images
|
||||
sphinxcontrib-jquery
|
||||
sphinxcontrib-spelling==8.*
|
||||
sphinxemoji
|
||||
pyenchant==3.2.*
|
||||
pyenchant==3.3.*
|
||||
|
||||
@@ -28,33 +28,33 @@ classifiers = [
|
||||
dependencies = [
|
||||
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
|
||||
"babel",
|
||||
"BeautifulSoup4==4.13.*",
|
||||
"BeautifulSoup4==4.14.*",
|
||||
"bleach==6.2.*",
|
||||
"celery==5.4.*",
|
||||
"celery==5.5.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=44.0.0",
|
||||
"css-inline==0.14.*",
|
||||
"css-inline==0.17.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"Django[argon2]==4.2.*,>=4.2.15",
|
||||
"django-bootstrap3==25.1",
|
||||
"Django[argon2]==4.2.*,>=4.2.24",
|
||||
"django-bootstrap3==25.2",
|
||||
"django-compressor==4.5.1",
|
||||
"django-countries==7.6.*",
|
||||
"django-filter==25.1",
|
||||
"django-formset-js-improved==0.5.0.3",
|
||||
"django-formset-js-improved==0.5.0.4",
|
||||
"django-formtools==2.5.1",
|
||||
"django-hierarkey==1.2.*",
|
||||
"django-hierarkey==2.0.*,>=2.0.1",
|
||||
"django-hijack==3.7.*",
|
||||
"django-i18nfield==1.10.*",
|
||||
"django-i18nfield==1.11.*",
|
||||
"django-libsass==0.9",
|
||||
"django-localflavor==4.0",
|
||||
"django-localflavor==5.0",
|
||||
"django-markup",
|
||||
"django-oauth-toolkit==2.3.*",
|
||||
"django-otp==1.5.*",
|
||||
"django-otp==1.6.*",
|
||||
"django-phonenumber-field==7.3.*",
|
||||
"django-redis==5.4.*",
|
||||
"django-redis==6.0.*",
|
||||
"django-scopes==2.0.*",
|
||||
"django-statici18n==2.6.*",
|
||||
"djangorestframework==3.15.*",
|
||||
"djangorestframework==3.16.*",
|
||||
"dnspython==2.7.*",
|
||||
"drf_ujson2==1.7.*",
|
||||
"geoip2==5.*",
|
||||
@@ -64,34 +64,34 @@ dependencies = [
|
||||
"kombu==5.5.*",
|
||||
"libsass==0.23.*",
|
||||
"lxml",
|
||||
"markdown==3.7", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
"markdown==3.9", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
|
||||
"mt-940==4.30.*",
|
||||
"oauthlib==3.2.*",
|
||||
"oauthlib==3.3.*",
|
||||
"openpyxl==3.1.*",
|
||||
"packaging",
|
||||
"paypalrestsdk==1.13.*",
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.10.*",
|
||||
"phonenumberslite==9.0.*",
|
||||
"Pillow==11.1.*",
|
||||
"Pillow==11.3.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==6.30.*",
|
||||
"protobuf==6.32.*",
|
||||
"psycopg2-binary",
|
||||
"pycountry",
|
||||
"pycparser==2.22",
|
||||
"pycryptodome==3.22.*",
|
||||
"pypdf==5.4.*",
|
||||
"pycparser==2.23",
|
||||
"pycryptodome==3.23.*",
|
||||
"pypdf==6.0.*",
|
||||
"python-bidi==0.6.*", # Support for Arabic in reportlab
|
||||
"python-dateutil==2.9.*",
|
||||
"pytz",
|
||||
"pytz-deprecation-shim==0.1.*",
|
||||
"pyuca",
|
||||
"qrcode==8.0",
|
||||
"redis==5.2.*",
|
||||
"reportlab==4.3.*",
|
||||
"requests==2.31.*",
|
||||
"sentry-sdk==2.24.*",
|
||||
"qrcode==8.2",
|
||||
"redis==6.4.*",
|
||||
"reportlab==4.4.*",
|
||||
"requests==2.32.*",
|
||||
"sentry-sdk==2.38.*",
|
||||
"sepaxml==2.6.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
@@ -100,30 +100,30 @@ dependencies = [
|
||||
"ua-parser==1.0.*",
|
||||
"vat_moss_forked==2020.3.20.0.11.0",
|
||||
"vobject==0.9.*",
|
||||
"webauthn==2.5.*",
|
||||
"webauthn==2.7.*",
|
||||
"zeep==4.3.*"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
memcached = ["pylibmc"]
|
||||
dev = [
|
||||
"aiohttp==3.11.*",
|
||||
"aiohttp==3.12.*",
|
||||
"coverage",
|
||||
"coveralls",
|
||||
"fakeredis==2.26.*",
|
||||
"flake8==7.1.*",
|
||||
"fakeredis==2.31.*",
|
||||
"flake8==7.3.*",
|
||||
"freezegun",
|
||||
"isort==6.0.*",
|
||||
"pep8-naming==0.14.*",
|
||||
"isort==6.1.*",
|
||||
"pep8-naming==0.15.*",
|
||||
"potypo",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-cache",
|
||||
"pytest-cov",
|
||||
"pytest-django==4.*",
|
||||
"pytest-mock==3.14.*",
|
||||
"pytest-mock==3.15.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.6.*",
|
||||
"pytest==8.3.*",
|
||||
"pytest-xdist==3.8.*",
|
||||
"pytest==8.4.*",
|
||||
"responses",
|
||||
]
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ coverage:
|
||||
coverage run -m py.test
|
||||
|
||||
npminstall:
|
||||
# keep this in sync with setup.py!
|
||||
# keep this in sync with pretix/_build.py!
|
||||
mkdir -p pretix/static.dist/node_prefix/
|
||||
cp -r pretix/static/npm_dir/* pretix/static.dist/node_prefix/
|
||||
npm install --prefix=pretix/static.dist/node_prefix
|
||||
npm ci --prefix=pretix/static.dist/node_prefix
|
||||
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "2025.4.0.dev0"
|
||||
__version__ = "2025.9.0.dev0"
|
||||
|
||||
@@ -101,6 +101,7 @@ ALL_LANGUAGES = [
|
||||
('fi', _('Finnish')),
|
||||
('gl', _('Galician')),
|
||||
('el', _('Greek')),
|
||||
('he', _('Hebrew')),
|
||||
('id', _('Indonesian')),
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
@@ -114,6 +115,7 @@ ALL_LANGUAGES = [
|
||||
('sk', _('Slovak')),
|
||||
('sv', _('Swedish')),
|
||||
('es', _('Spanish')),
|
||||
('es-419', _('Spanish (Latin America)')),
|
||||
('tr', _('Turkish')),
|
||||
('uk', _('Ukrainian')),
|
||||
]
|
||||
@@ -121,7 +123,8 @@ LANGUAGES_OFFICIAL = {
|
||||
'en', 'de', 'de-informal'
|
||||
}
|
||||
LANGUAGES_RTL = {
|
||||
'ar', 'hw'
|
||||
# When adding more right-to-left languages, also update pretix/static/pretixbase/scss/_rtl.scss
|
||||
'ar', 'he'
|
||||
}
|
||||
LANGUAGES_INCUBATING = {
|
||||
'pt-br', 'gl',
|
||||
@@ -170,6 +173,12 @@ EXTRA_LANG_INFO = {
|
||||
'name': 'Norwegian Bokmal',
|
||||
'name_local': 'norsk (bokmål)',
|
||||
},
|
||||
'es-419': {
|
||||
'bidi': False,
|
||||
'code': 'es-419',
|
||||
'name': 'Spanish (Latin America)',
|
||||
'name_local': 'Español',
|
||||
},
|
||||
}
|
||||
|
||||
django.conf.locale.LANG_INFO.update(EXTRA_LANG_INFO)
|
||||
|
||||
@@ -39,7 +39,7 @@ def npm_install():
|
||||
node_prefix = os.path.join(here, 'static.dist', 'node_prefix')
|
||||
os.makedirs(node_prefix, exist_ok=True)
|
||||
shutil.copytree(os.path.join(here, 'static', 'npm_dir'), node_prefix, dirs_exist_ok=True)
|
||||
subprocess.check_call('npm install', shell=True, cwd=node_prefix)
|
||||
subprocess.check_call('npm ci', shell=True, cwd=node_prefix)
|
||||
npm_installed = True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.17 on 2025-06-24 14:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixapi", "0012_oauthapplication_post_logout_redirect_uris"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="webhookcallretry",
|
||||
name="retry_not_before",
|
||||
field=models.DateTimeField(),
|
||||
),
|
||||
]
|
||||
@@ -157,7 +157,7 @@ class WebHookCallRetry(models.Model):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='retries')
|
||||
logentry = models.ForeignKey('pretixbase.LogEntry', on_delete=models.CASCADE, related_name='webhook_retries')
|
||||
retry_not_before = models.DateTimeField(auto_now_add=True)
|
||||
retry_not_before = models.DateTimeField()
|
||||
retry_count = models.PositiveIntegerField(default=0)
|
||||
action_type = models.CharField(max_length=255)
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
|
||||
type = serializers.ChoiceField(choices=Checkin.CHECKIN_TYPES, default=Checkin.TYPE_ENTRY)
|
||||
ignore_unpaid = serializers.BooleanField(default=False, required=False)
|
||||
questions_supported = serializers.BooleanField(default=True, required=False)
|
||||
use_order_locale = serializers.BooleanField(default=False, required=False)
|
||||
nonce = serializers.CharField(required=False, allow_null=True)
|
||||
datetime = serializers.DateTimeField(required=False, allow_null=True)
|
||||
answers = serializers.JSONField(required=False, allow_null=True)
|
||||
@@ -103,3 +104,14 @@ class MiniCheckinListSerializer(I18nAwareModelSerializer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class CheckinRPCAnnulInputSerializer(serializers.Serializer):
|
||||
lists = serializers.PrimaryKeyRelatedField(required=True, many=True, queryset=CheckinList.objects.none())
|
||||
nonce = serializers.CharField(required=True, allow_null=False)
|
||||
datetime = serializers.DateTimeField(required=False, allow_null=True)
|
||||
error_explanation = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event')
|
||||
|
||||
@@ -50,6 +50,7 @@ from rest_framework.relations import SlugRelatedField
|
||||
from pretix.api.serializers import (
|
||||
CompatibleJSONField, SalesChannelMigrationMixin,
|
||||
)
|
||||
from pretix.api.serializers.fields import PluginsField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.settings import SettingsSerializer
|
||||
from pretix.base.models import (
|
||||
@@ -61,6 +62,9 @@ from pretix.base.models.items import (
|
||||
ItemMetaProperty, SubEventItem, SubEventItemVariation,
|
||||
)
|
||||
from pretix.base.models.tax import CustomRulesValidator
|
||||
from pretix.base.plugins import (
|
||||
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
|
||||
)
|
||||
from pretix.base.services.seating import (
|
||||
SeatProtected, generate_seats, validate_plan_change,
|
||||
)
|
||||
@@ -126,22 +130,6 @@ class SeatCategoryMappingField(Field):
|
||||
}
|
||||
|
||||
|
||||
class PluginsField(Field):
|
||||
|
||||
def to_representation(self, obj):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
return sorted([
|
||||
p.module for p in get_all_plugins()
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
|
||||
])
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return {
|
||||
'plugins': data
|
||||
}
|
||||
|
||||
|
||||
class TimeZoneField(ChoiceField):
|
||||
def get_attribute(self, instance):
|
||||
return instance.cache.get_or_set(
|
||||
@@ -283,17 +271,28 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
plugins_available = {
|
||||
p.module: p for p in get_all_plugins(self.instance)
|
||||
p.module: p for p in get_all_plugins(event=self.instance)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
current_plugins = self.instance.get_plugins() if self.instance and self.instance.pk else []
|
||||
settings_holder = self.instance if self.instance and self.instance.pk else self.context['organizer']
|
||||
|
||||
allowed_levels = (PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID)
|
||||
for plugin in value.get('plugins'):
|
||||
if plugin not in plugins_available:
|
||||
raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin))
|
||||
if getattr(plugins_available[plugin], 'restricted', False):
|
||||
if plugin not in settings_holder.settings.allowed_restricted_plugins:
|
||||
raise ValidationError(_('Restricted plugin: \'{name}\'.').format(name=plugin))
|
||||
level = getattr(plugins_available[plugin], 'level', PLUGIN_LEVEL_EVENT)
|
||||
if level not in allowed_levels:
|
||||
raise ValidationError('Plugin cannot be enabled on this level: \'{name}\'.'.format(name=plugin))
|
||||
|
||||
if level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and plugin not in self.context['organizer'].get_plugins():
|
||||
if plugin not in current_plugins:
|
||||
# Technically, this is allowed, but consumers might be confused if the API call doesn't do anything
|
||||
# so we prevent this change.
|
||||
raise ValidationError('Plugin should be enabled on organizer level first: \'{name}\'.'.format(name=plugin))
|
||||
|
||||
return value
|
||||
|
||||
@@ -378,6 +377,8 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
if prop.name not in meta_data:
|
||||
current_object.delete()
|
||||
|
||||
instance._prefetched_objects_cache.clear()
|
||||
|
||||
# Item Meta properties
|
||||
if item_meta_properties is not None:
|
||||
current = list(event.item_meta_properties.all())
|
||||
@@ -398,6 +399,8 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
if prop.name not in list(item_meta_properties.keys()):
|
||||
prop.delete()
|
||||
|
||||
instance._prefetched_objects_cache.clear()
|
||||
|
||||
# Seats
|
||||
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
|
||||
current_mappings = {
|
||||
@@ -681,8 +684,26 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = TaxRule
|
||||
fields = ('id', 'name', 'rate', 'code', 'price_includes_tax', 'eu_reverse_charge', 'home_country',
|
||||
'internal_name', 'keep_gross_if_rate_changes', 'custom_rules')
|
||||
fields = ('id', 'name', 'default', 'rate', 'code', 'price_includes_tax', 'eu_reverse_charge', 'home_country',
|
||||
'internal_name', 'keep_gross_if_rate_changes', 'custom_rules', 'default')
|
||||
|
||||
def create(self, validated_data):
|
||||
if "default" not in validated_data and not self.context["event"].tax_rules.exists():
|
||||
validated_data["default"] = True
|
||||
return super().create(validated_data)
|
||||
|
||||
def save(self, **kwargs):
|
||||
if self.validated_data.get("default"):
|
||||
if self.instance and self.instance.pk:
|
||||
self.context["event"].tax_rules.exclude(pk=self.instance.pk).update(default=False)
|
||||
else:
|
||||
self.context["event"].tax_rules.update(default=False)
|
||||
return super().save(**kwargs)
|
||||
|
||||
def validate_default(self, value):
|
||||
if not value and self.instance.default:
|
||||
raise ValidationError("You can't remove the default property, instead set it on another tax rule.")
|
||||
return value
|
||||
|
||||
|
||||
class EventSettingsSerializer(SettingsSerializer):
|
||||
@@ -708,6 +729,8 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'allow_modifications_after_checkin',
|
||||
'last_order_modification_date',
|
||||
'show_quota_left',
|
||||
'tax_rule_payment',
|
||||
'tax_rule_cancellation',
|
||||
'waiting_list_enabled',
|
||||
'waiting_list_auto_disable',
|
||||
'waiting_list_hours',
|
||||
@@ -782,6 +805,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'invoice_reissue_after_modify',
|
||||
'invoice_include_free',
|
||||
'invoice_generate',
|
||||
'invoice_period',
|
||||
'invoice_numbers_consecutive',
|
||||
'invoice_numbers_prefix',
|
||||
'invoice_numbers_prefix_cancellations',
|
||||
@@ -938,6 +962,8 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'reusable_media_type_nfc_mf0aes',
|
||||
'reusable_media_type_nfc_mf0aes_random_uid',
|
||||
'system_question_order',
|
||||
'tax_rule_payment',
|
||||
'tax_rule_cancellation',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -19,45 +19,16 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.http import QueryDict
|
||||
from pytz import common_timezones
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.forms import form_field_to_serializer_field
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
from pretix.base.models import ScheduledEventExport, ScheduledOrganizerExport
|
||||
from pretix.base.timeframes import DateFrameField, SerializerDateFrameField
|
||||
|
||||
|
||||
class FormFieldWrapperField(serializers.Field):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.form_field = kwargs.pop('form_field')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_representation(self, value):
|
||||
return self.form_field.widget.format_value(value)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
d = self.form_field.widget.value_from_datadict({'name': data}, {}, 'name')
|
||||
d = self.form_field.clean(d)
|
||||
return d
|
||||
|
||||
|
||||
simple_mappings = (
|
||||
(forms.DateField, serializers.DateField, ()),
|
||||
(forms.TimeField, serializers.TimeField, ()),
|
||||
(forms.SplitDateTimeField, serializers.DateTimeField, ()),
|
||||
(forms.DateTimeField, serializers.DateTimeField, ()),
|
||||
(forms.DecimalField, serializers.DecimalField, ('max_digits', 'decimal_places', 'min_value', 'max_value')),
|
||||
(forms.FloatField, serializers.FloatField, ()),
|
||||
(forms.IntegerField, serializers.IntegerField, ()),
|
||||
(forms.EmailField, serializers.EmailField, ()),
|
||||
(forms.UUIDField, serializers.UUIDField, ()),
|
||||
(forms.URLField, serializers.URLField, ()),
|
||||
(forms.BooleanField, serializers.BooleanField, ()),
|
||||
)
|
||||
from pretix.base.timeframes import SerializerDateFrameField
|
||||
|
||||
|
||||
class SerializerDescriptionField(serializers.Field):
|
||||
@@ -81,13 +52,6 @@ class ExporterSerializer(serializers.Serializer):
|
||||
input_parameters = SerializerDescriptionField(source='_serializer')
|
||||
|
||||
|
||||
class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
|
||||
def to_representation(self, value):
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
return super().to_representation(value)
|
||||
|
||||
|
||||
class JobRunSerializer(serializers.Serializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
ex = kwargs.pop('exporter')
|
||||
@@ -102,59 +66,7 @@ class JobRunSerializer(serializers.Serializer):
|
||||
many=True
|
||||
)
|
||||
for k, v in ex.export_form_fields.items():
|
||||
for m_from, m_to, m_kwargs in simple_mappings:
|
||||
if isinstance(v, m_from):
|
||||
self.fields[k] = m_to(
|
||||
required=v.required,
|
||||
allow_null=not v.required,
|
||||
validators=v.validators,
|
||||
**{kwarg: getattr(v, kwargs, None) for kwarg in m_kwargs}
|
||||
)
|
||||
break
|
||||
|
||||
if isinstance(v, forms.NullBooleanField):
|
||||
self.fields[k] = serializers.BooleanField(
|
||||
required=v.required,
|
||||
allow_null=True,
|
||||
validators=v.validators,
|
||||
)
|
||||
if isinstance(v, forms.ModelMultipleChoiceField):
|
||||
self.fields[k] = PrimaryKeyRelatedField(
|
||||
queryset=v.queryset,
|
||||
required=v.required,
|
||||
allow_empty=not v.required,
|
||||
validators=v.validators,
|
||||
many=True
|
||||
)
|
||||
elif isinstance(v, forms.ModelChoiceField):
|
||||
self.fields[k] = PrimaryKeyRelatedField(
|
||||
queryset=v.queryset,
|
||||
required=v.required,
|
||||
allow_null=not v.required,
|
||||
validators=v.validators,
|
||||
)
|
||||
elif isinstance(v, forms.MultipleChoiceField):
|
||||
self.fields[k] = serializers.MultipleChoiceField(
|
||||
choices=v.choices,
|
||||
required=v.required,
|
||||
allow_empty=not v.required,
|
||||
validators=v.validators,
|
||||
)
|
||||
elif isinstance(v, forms.ChoiceField):
|
||||
self.fields[k] = serializers.ChoiceField(
|
||||
choices=v.choices,
|
||||
required=v.required,
|
||||
allow_null=not v.required,
|
||||
validators=v.validators,
|
||||
)
|
||||
elif isinstance(v, DateFrameField):
|
||||
self.fields[k] = SerializerDateFrameField(
|
||||
required=v.required,
|
||||
allow_null=not v.required,
|
||||
validators=v.validators,
|
||||
)
|
||||
else:
|
||||
self.fields[k] = FormFieldWrapperField(form_field=v, required=v.required, allow_null=not v.required)
|
||||
self.fields[k] = form_field_to_serializer_field(v)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if isinstance(data, QueryDict):
|
||||
|
||||
@@ -109,3 +109,19 @@ class UploadedFileField(serializers.Field):
|
||||
return None
|
||||
request = self.context['request']
|
||||
return request.build_absolute_uri(url)
|
||||
|
||||
|
||||
class PluginsField(serializers.Field):
|
||||
|
||||
def to_representation(self, obj):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
return sorted([
|
||||
p.module for p in get_all_plugins()
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
|
||||
])
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return {
|
||||
'plugins': data
|
||||
}
|
||||
|
||||
115
src/pretix/api/serializers/forms.py
Normal file
115
src/pretix/api/serializers/forms.py
Normal file
@@ -0,0 +1,115 @@
|
||||
#
|
||||
# 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 forms
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.base.timeframes import DateFrameField, SerializerDateFrameField
|
||||
|
||||
simple_mappings = (
|
||||
(forms.DateField, serializers.DateField, ()),
|
||||
(forms.TimeField, serializers.TimeField, ()),
|
||||
(forms.SplitDateTimeField, serializers.DateTimeField, ()),
|
||||
(forms.DateTimeField, serializers.DateTimeField, ()),
|
||||
(forms.DecimalField, serializers.DecimalField, ('max_digits', 'decimal_places', 'min_value', 'max_value')),
|
||||
(forms.FloatField, serializers.FloatField, ()),
|
||||
(forms.IntegerField, serializers.IntegerField, ()),
|
||||
(forms.EmailField, serializers.EmailField, ()),
|
||||
(forms.UUIDField, serializers.UUIDField, ()),
|
||||
(forms.URLField, serializers.URLField, ()),
|
||||
(forms.BooleanField, serializers.BooleanField, ()),
|
||||
)
|
||||
|
||||
|
||||
class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
|
||||
def to_representation(self, value):
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
return super().to_representation(value)
|
||||
|
||||
|
||||
class FormFieldWrapperField(serializers.Field):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.form_field = kwargs.pop('form_field')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_representation(self, value):
|
||||
return self.form_field.widget.format_value(value)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
d = self.form_field.widget.value_from_datadict({'name': data}, {}, 'name')
|
||||
d = self.form_field.clean(d)
|
||||
return d
|
||||
|
||||
|
||||
def form_field_to_serializer_field(field):
|
||||
for m_from, m_to, m_kwargs in simple_mappings:
|
||||
if isinstance(field, m_from):
|
||||
return m_to(
|
||||
required=field.required,
|
||||
allow_null=not field.required,
|
||||
validators=field.validators,
|
||||
**{kwarg: getattr(field, kwarg, None) for kwarg in m_kwargs}
|
||||
)
|
||||
|
||||
if isinstance(field, forms.NullBooleanField):
|
||||
return serializers.BooleanField(
|
||||
required=field.required,
|
||||
allow_null=True,
|
||||
validators=field.validators,
|
||||
)
|
||||
if isinstance(field, forms.ModelMultipleChoiceField):
|
||||
return PrimaryKeyRelatedField(
|
||||
queryset=field.queryset,
|
||||
required=field.required,
|
||||
allow_empty=not field.required,
|
||||
validators=field.validators,
|
||||
many=True
|
||||
)
|
||||
elif isinstance(field, forms.ModelChoiceField):
|
||||
return PrimaryKeyRelatedField(
|
||||
queryset=field.queryset,
|
||||
required=field.required,
|
||||
allow_null=not field.required,
|
||||
validators=field.validators,
|
||||
)
|
||||
elif isinstance(field, forms.MultipleChoiceField):
|
||||
return serializers.MultipleChoiceField(
|
||||
choices=field.choices,
|
||||
required=field.required,
|
||||
allow_empty=not field.required,
|
||||
validators=field.validators,
|
||||
)
|
||||
elif isinstance(field, forms.ChoiceField):
|
||||
return serializers.ChoiceField(
|
||||
choices=field.choices,
|
||||
required=field.required,
|
||||
allow_null=not field.required,
|
||||
validators=field.validators,
|
||||
)
|
||||
elif isinstance(field, DateFrameField):
|
||||
return SerializerDateFrameField(
|
||||
required=field.required,
|
||||
allow_null=not field.required,
|
||||
validators=field.validators,
|
||||
)
|
||||
else:
|
||||
return FormFieldWrapperField(form_field=field, required=field.required, allow_null=not field.required)
|
||||
@@ -505,6 +505,11 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
return value
|
||||
|
||||
def validate_type(self, value):
|
||||
if self.instance:
|
||||
self.instance.clean_type_change(self.instance.type, value)
|
||||
return value
|
||||
|
||||
def validate_dependency_question(self, value):
|
||||
if value:
|
||||
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
|
||||
@@ -577,7 +582,7 @@ class QuotaSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Quota
|
||||
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out',
|
||||
'release_after_exit', 'available', 'available_number')
|
||||
'release_after_exit', 'available', 'available_number', 'ignore_for_event_availability')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -42,6 +42,7 @@ from rest_framework.reverse import reverse
|
||||
|
||||
from pretix.api.serializers import CompatibleJSONField
|
||||
from pretix.api.serializers.event import SubEventSerializer
|
||||
from pretix.api.serializers.forms import form_field_to_serializer_field
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.item import (
|
||||
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
|
||||
@@ -49,6 +50,7 @@ from pretix.api.serializers.item import (
|
||||
from pretix.api.signals import order_api_details, orderposition_api_details
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.invoicing.transmission import get_transmission_types
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
|
||||
ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
|
||||
@@ -56,7 +58,7 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
PrintLog, RevokedTicketSecret,
|
||||
PrintLog, RevokedTicketSecret, Transaction,
|
||||
)
|
||||
from pretix.base.pdf import get_images, get_variables
|
||||
from pretix.base.services.cart import error_messages
|
||||
@@ -102,6 +104,13 @@ class CountryField(serializers.Field):
|
||||
return str(src) if src else None
|
||||
|
||||
|
||||
class TransmissionInfoSerializer(serializers.Serializer):
|
||||
def __init__(self, *args, transmission_type, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for k, v in transmission_type.invoice_address_form_fields.items():
|
||||
self.fields[k] = form_field_to_serializer_field(v)
|
||||
|
||||
|
||||
class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
country = CompatibleCountryField(source='*')
|
||||
name = serializers.CharField(required=False)
|
||||
@@ -109,7 +118,8 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country',
|
||||
'state', 'vat_id', 'vat_id_validated', 'custom_field', 'internal_reference')
|
||||
'state', 'vat_id', 'vat_id_validated', 'custom_field', 'internal_reference', 'transmission_type',
|
||||
'transmission_info')
|
||||
read_only_fields = ('last_modified',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -147,6 +157,48 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
|
||||
)
|
||||
|
||||
if data.get("transmission_type"):
|
||||
for t in get_transmission_types():
|
||||
if data.get("transmission_type") == t.identifier:
|
||||
if not t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
|
||||
raise ValidationError({
|
||||
"transmission_type": "The selected transmission type is not available for this country or address type."
|
||||
})
|
||||
|
||||
ts = TransmissionInfoSerializer(transmission_type=t, data=data.get("transmission_info", {}))
|
||||
try:
|
||||
ts.is_valid(raise_exception=True)
|
||||
except ValidationError as e:
|
||||
raise ValidationError(
|
||||
{"transmission_info": e.detail}
|
||||
)
|
||||
data["transmission_info"] = ts.validated_data
|
||||
|
||||
required_fields = t.invoice_address_form_fields_required(data.get("country"), data.get("is_business"))
|
||||
for r in required_fields:
|
||||
if r in self.fields:
|
||||
if not data.get(r):
|
||||
raise ValidationError(
|
||||
{r: "This field is required for the selected type of invoice transmission."}
|
||||
)
|
||||
else:
|
||||
if not ts.validated_data.get(r):
|
||||
raise ValidationError(
|
||||
{"transmission_info": {r: "This field is required for the selected type of invoice transmission."}}
|
||||
)
|
||||
break # do not call else branch of for loop
|
||||
elif t.exclusive:
|
||||
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
|
||||
raise ValidationError({
|
||||
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
|
||||
t.identifier,
|
||||
)
|
||||
})
|
||||
else:
|
||||
raise ValidationError(
|
||||
{"transmission_type": "Unknown transmission type."}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -607,6 +659,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
||||
order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order')
|
||||
order__valid_if_pending = serializers.SlugRelatedField(read_only=True, slug_field='valid_if_pending', source='order')
|
||||
order__require_approval = serializers.SlugRelatedField(read_only=True, slug_field='require_approval', source='order')
|
||||
order__locale = serializers.SlugRelatedField(read_only=True, slug_field='locale', source='order')
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
@@ -615,7 +668,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||
'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat',
|
||||
'require_attention', 'order__status', 'order__valid_if_pending', 'order__require_approval',
|
||||
'valid_from', 'valid_until', 'blocked')
|
||||
'order__locale', 'valid_from', 'valid_until', 'blocked')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -1599,7 +1652,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
self.context['event'].currency)
|
||||
is_split_taxes = fee_data.pop('_split_taxes_like_products', False)
|
||||
|
||||
if is_split_taxes:
|
||||
if is_split_taxes and order.total:
|
||||
d = defaultdict(lambda: Decimal('0.00'))
|
||||
trz = TaxRule.zero()
|
||||
for p in pos_map.values():
|
||||
@@ -1704,12 +1757,14 @@ class LinePositionField(serializers.IntegerField):
|
||||
|
||||
class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
||||
position = LinePositionField(read_only=True)
|
||||
event_date_from = serializers.DateTimeField(read_only=True, source="period_start")
|
||||
event_date_to = serializers.DateTimeField(read_only=True, source="period_end")
|
||||
|
||||
class Meta:
|
||||
model = InvoiceLine
|
||||
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
|
||||
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_code', 'tax_name', 'fee_type',
|
||||
'fee_internal_type', 'event_location')
|
||||
'event_date_to', 'period_start', 'period_end', 'gross_value', 'tax_value', 'tax_rate', 'tax_code',
|
||||
'tax_name', 'fee_type', 'fee_internal_type', 'event_location')
|
||||
|
||||
|
||||
class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
@@ -1724,12 +1779,13 @@ class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
model = Invoice
|
||||
fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
|
||||
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
|
||||
'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode',
|
||||
'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary',
|
||||
'custom_field', 'date', 'refers', 'locale',
|
||||
'invoice_to', 'invoice_to_is_business', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street',
|
||||
'invoice_to_zipcode', 'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id',
|
||||
'invoice_to_beneficiary', 'invoice_to_transmission_info', 'custom_field', 'date', 'refers', 'locale',
|
||||
'introductory_text', 'additional_text', 'payment_provider_text', 'payment_provider_stamp',
|
||||
'footer_text', 'lines', 'foreign_currency_display', 'foreign_currency_rate',
|
||||
'foreign_currency_rate_date', 'internal_reference')
|
||||
'foreign_currency_rate_date', 'internal_reference', 'transmission_type', 'transmission_provider',
|
||||
'transmission_status', 'transmission_date')
|
||||
|
||||
|
||||
class OrderPaymentCreateSerializer(I18nAwareModelSerializer):
|
||||
@@ -1782,3 +1838,23 @@ class BlockedTicketSecretSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = BlockedTicketSecret
|
||||
fields = ('id', 'secret', 'updated', 'blocked')
|
||||
|
||||
|
||||
class TransactionSerializer(I18nAwareModelSerializer):
|
||||
order = serializers.SlugRelatedField(slug_field="code", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = (
|
||||
"id", "order", "created", "datetime", "positionid", "count", "item", "variation",
|
||||
"subevent", "price", "tax_rate", "tax_rule", "tax_code", "tax_value", "fee_type",
|
||||
"internal_type"
|
||||
)
|
||||
|
||||
|
||||
class OrganizerTransactionSerializer(TransactionSerializer):
|
||||
event = serializers.SlugRelatedField(source="order.event", slug_field="slug", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = TransactionSerializer.Meta.fields + ("event",)
|
||||
|
||||
@@ -83,6 +83,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
|
||||
|
||||
def create(self, validated_data):
|
||||
ocm = self.context['ocm']
|
||||
check_quotas = self.context.get('check_quotas', True)
|
||||
|
||||
try:
|
||||
ocm.add_position(
|
||||
@@ -96,7 +97,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
|
||||
valid_until=validated_data.get('valid_until'),
|
||||
)
|
||||
if self.context.get('commit', True):
|
||||
ocm.commit()
|
||||
ocm.commit(check_quotas=check_quotas)
|
||||
return validated_data['order'].positions.order_by('-positionid').first()
|
||||
else:
|
||||
return OrderPosition() # fake to appease DRF
|
||||
@@ -251,7 +252,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = (
|
||||
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule', 'valid_from', 'valid_until'
|
||||
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule', 'valid_from', 'valid_until', 'secret'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -310,6 +311,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
ocm = self.context['ocm']
|
||||
check_quotas = self.context.get('check_quotas', True)
|
||||
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)
|
||||
@@ -319,6 +321,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
|
||||
tax_rule = validated_data.get('tax_rule', instance.tax_rule)
|
||||
valid_from = validated_data.get('valid_from', instance.valid_from)
|
||||
valid_until = validated_data.get('valid_until', instance.valid_until)
|
||||
secret = validated_data.get('secret', instance.secret)
|
||||
|
||||
change_item = None
|
||||
if item != instance.item or variation != instance.variation:
|
||||
@@ -351,8 +354,11 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
|
||||
if valid_until != instance.valid_until:
|
||||
ocm.change_valid_until(instance, valid_until)
|
||||
|
||||
if secret != instance.secret:
|
||||
ocm.change_ticket_secret(instance, secret)
|
||||
|
||||
if self.context.get('commit', True):
|
||||
ocm.commit()
|
||||
ocm.commit(check_quotas=check_quotas)
|
||||
instance.refresh_from_db()
|
||||
except OrderError as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
@@ -24,6 +24,7 @@ from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -32,6 +33,7 @@ from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.auth.devicesecurity import get_all_security_profiles
|
||||
from pretix.api.serializers import AsymmetricField
|
||||
from pretix.api.serializers.fields import PluginsField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import CompatibleJSONField
|
||||
from pretix.api.serializers.settings import SettingsSerializer
|
||||
@@ -43,6 +45,10 @@ from pretix.base.models import (
|
||||
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||
from pretix.base.plugins import (
|
||||
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
|
||||
PLUGIN_LEVEL_ORGANIZER,
|
||||
)
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.base.settings import validate_organizer_settings
|
||||
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
||||
@@ -53,13 +59,47 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class OrganizerSerializer(I18nAwareModelSerializer):
|
||||
public_url = serializers.SerializerMethodField('get_organizer_url', read_only=True)
|
||||
plugins = PluginsField(required=False, source='*')
|
||||
name = serializers.CharField(read_only=True)
|
||||
slug = serializers.CharField(read_only=True)
|
||||
|
||||
def get_organizer_url(self, organizer):
|
||||
return build_absolute_uri(organizer, 'presale:organizer.index')
|
||||
|
||||
class Meta:
|
||||
model = Organizer
|
||||
fields = ('name', 'slug', 'public_url')
|
||||
fields = ('name', 'slug', 'public_url', 'plugins')
|
||||
|
||||
def validate_plugins(self, value):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
plugins_available = {
|
||||
p.module: p for p in get_all_plugins(organizer=self.instance)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
settings_holder = self.instance
|
||||
|
||||
allowed_levels = (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID)
|
||||
for plugin in value.get('plugins'):
|
||||
if plugin not in plugins_available:
|
||||
raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin))
|
||||
if getattr(plugins_available[plugin], 'restricted', False):
|
||||
if plugin not in settings_holder.settings.allowed_restricted_plugins:
|
||||
raise ValidationError(_('Restricted plugin: \'{name}\'.').format(name=plugin))
|
||||
if getattr(plugins_available[plugin], 'level', PLUGIN_LEVEL_EVENT) not in allowed_levels:
|
||||
raise ValidationError('Plugin cannot be enabled on this level: \'{name}\'.'.format(name=plugin))
|
||||
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
plugins = validated_data.pop('plugins', None)
|
||||
organizer = super().update(instance, validated_data)
|
||||
# Plugins
|
||||
if plugins is not None:
|
||||
organizer.set_active_plugins(plugins)
|
||||
organizer.save()
|
||||
return organizer
|
||||
|
||||
|
||||
class SeatingPlanSerializer(I18nAwareModelSerializer):
|
||||
@@ -426,6 +466,9 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
'organizer_logo_image_inherit',
|
||||
'organizer_logo_image',
|
||||
'privacy_url',
|
||||
'accessibility_url',
|
||||
'accessibility_title',
|
||||
'accessibility_text',
|
||||
'cookie_consent',
|
||||
'cookie_consent_dialog_title',
|
||||
'cookie_consent_dialog_text',
|
||||
@@ -441,6 +484,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
'reusable_media_type_nfc_mf0aes',
|
||||
'reusable_media_type_nfc_mf0aes_autocreate_giftcard',
|
||||
'reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency',
|
||||
'reusable_media_type_nfc_mf0aes_random_uid',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
@@ -64,14 +66,15 @@ class SeatGuidField(serializers.CharField):
|
||||
|
||||
class VoucherSerializer(I18nAwareModelSerializer):
|
||||
seat = SeatGuidField(allow_null=True, required=False)
|
||||
budget_used = serializers.DecimalField(read_only=True, max_digits=13, decimal_places=2, min_value=Decimal('0.00'))
|
||||
|
||||
class Meta:
|
||||
model = Voucher
|
||||
fields = ('id', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota',
|
||||
fields = ('id', 'created', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota',
|
||||
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
|
||||
'tag', 'comment', 'subevent', 'show_hidden_items', 'seat', 'all_addons_included',
|
||||
'all_bundles_included')
|
||||
read_only_fields = ('id', 'redeemed')
|
||||
'all_bundles_included', 'budget', 'budget_used')
|
||||
read_only_fields = ('id', 'redeemed', 'budget_used')
|
||||
list_serializer_class = VoucherListSerializer
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
@@ -21,22 +21,22 @@
|
||||
#
|
||||
from datetime import timedelta
|
||||
|
||||
from django.dispatch import Signal, receiver
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.api.models import ApiCall, WebHookCall
|
||||
from pretix.base.signals import EventPluginSignal, periodic_task
|
||||
from pretix.base.signals import EventPluginSignal, GlobalSignal, periodic_task
|
||||
from pretix.helpers.periodic import minimum_interval
|
||||
|
||||
register_webhook_events = Signal()
|
||||
register_webhook_events = GlobalSignal()
|
||||
"""
|
||||
This signal is sent out to get all known webhook events. Receivers should return an
|
||||
instance of a subclass of ``pretix.api.webhooks.WebhookEvent`` or a list of such
|
||||
instances.
|
||||
"""
|
||||
|
||||
register_device_security_profile = Signal()
|
||||
register_device_security_profile = GlobalSignal()
|
||||
"""
|
||||
This signal is sent out to get all known device security_profiles. Receivers should
|
||||
return an instance of a subclass of ``pretix.api.auth.devicesecurity.BaseSecurityProfile``
|
||||
|
||||
@@ -66,6 +66,7 @@ orga_router.register(r'orders', order.OrganizerOrderViewSet)
|
||||
orga_router.register(r'invoices', order.InvoiceViewSet)
|
||||
orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportViewSet)
|
||||
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
|
||||
orga_router.register(r'transactions', order.OrganizerTransactionViewSet)
|
||||
|
||||
team_router = routers.DefaultRouter()
|
||||
team_router.register(r'members', organizer.TeamMemberViewSet)
|
||||
@@ -83,6 +84,7 @@ event_router.register(r'quotas', item.QuotaViewSet)
|
||||
event_router.register(r'vouchers', voucher.VoucherViewSet)
|
||||
event_router.register(r'orders', order.EventOrderViewSet)
|
||||
event_router.register(r'orderpositions', order.OrderPositionViewSet)
|
||||
event_router.register(r'transactions', order.TransactionViewSet)
|
||||
event_router.register(r'invoices', order.InvoiceViewSet)
|
||||
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
|
||||
event_router.register(r'blockedsecrets', order.BlockedSecretViewSet, basename='blockedsecrets')
|
||||
@@ -130,6 +132,8 @@ urlpatterns = [
|
||||
name="checkinrpc.redeem"),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/search/$', checkin.CheckinRPCSearchView.as_view(),
|
||||
name="checkinrpc.search"),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/annul/$', checkin.CheckinRPCAnnulView.as_view(),
|
||||
name="checkinrpc.annul"),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/settings/$', organizer.OrganizerSettingsView.as_view(),
|
||||
name="organizer.settings"),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/giftcards/(?P<giftcard>[^/]+)/', include(giftcard_router.urls)),
|
||||
|
||||
@@ -20,12 +20,13 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import operator
|
||||
from datetime import timedelta
|
||||
from functools import reduce
|
||||
|
||||
import django_filters
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as BaseValidationError
|
||||
from django.db import transaction
|
||||
from django.db import connection, transaction
|
||||
from django.db.models import (
|
||||
Count, Exists, F, Max, OrderBy, OuterRef, Prefetch, Q, Subquery,
|
||||
prefetch_related_objects,
|
||||
@@ -39,17 +40,19 @@ from django.utils.translation import gettext
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from packaging.version import parse
|
||||
from rest_framework import views, viewsets
|
||||
from rest_framework import status, views, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.exceptions import (
|
||||
NotFound, PermissionDenied, ValidationError,
|
||||
)
|
||||
from rest_framework.fields import DateTimeField
|
||||
from rest_framework.generics import ListAPIView
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.checkin import (
|
||||
CheckinListSerializer, CheckinRPCRedeemInputSerializer,
|
||||
MiniCheckinListSerializer,
|
||||
CheckinListSerializer, CheckinRPCAnnulInputSerializer,
|
||||
CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
|
||||
)
|
||||
from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
@@ -66,6 +69,8 @@ from pretix.base.models.orders import PrintLog
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
|
||||
)
|
||||
from pretix.base.signals import checkin_annulled
|
||||
from pretix.helpers import OF_SELF
|
||||
|
||||
with scopes_disabled():
|
||||
class CheckinListFilter(FilterSet):
|
||||
@@ -420,7 +425,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
||||
|
||||
def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce,
|
||||
untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported,
|
||||
source_type='barcode', legacy_url_support=False, simulate=False, gate=None):
|
||||
source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False):
|
||||
if not checkinlists:
|
||||
raise ValidationError('No check-in list passed.')
|
||||
|
||||
@@ -694,7 +699,11 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
pass
|
||||
|
||||
# 6. Pass to our actual check-in logic
|
||||
with language(op.order.event.settings.locale):
|
||||
if use_order_locale:
|
||||
locale = op.order.locale
|
||||
else:
|
||||
locale = op.order.event.settings.locale
|
||||
with language(locale):
|
||||
try:
|
||||
perform_checkin(
|
||||
op=op,
|
||||
@@ -809,7 +818,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['expand'] = self.request.query_params.getlist('expand')
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
|
||||
return ctx
|
||||
|
||||
def get_filterset_kwargs(self):
|
||||
@@ -828,9 +837,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
def get_queryset(self, ignore_status=False, ignore_products=False):
|
||||
qs = _checkin_list_position_queryset(
|
||||
[self.checkinlist],
|
||||
ignore_status=self.request.query_params.get('ignore_status', 'false') == 'true' or ignore_status,
|
||||
ignore_status=self.request.query_params.get('ignore_status', 'false').lower() == 'true' or ignore_status,
|
||||
ignore_products=ignore_products,
|
||||
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
|
||||
pdf_data=self.request.query_params.get('pdf_data', 'false').lower() == 'true',
|
||||
expand=self.request.query_params.getlist('expand'),
|
||||
)
|
||||
|
||||
@@ -872,7 +881,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
expand=self.request.query_params.getlist('expand'),
|
||||
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
|
||||
pdf_data=self.request.query_params.get('pdf_data', 'false').lower() == 'true',
|
||||
questions_supported=self.request.data.get('questions_supported', True),
|
||||
canceled_supported=self.request.data.get('canceled_supported', False),
|
||||
request=self.request, # this is not clean, but we need it in the serializers for URL generation
|
||||
@@ -907,8 +916,9 @@ class CheckinRPCRedeemView(views.APIView):
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
expand=self.request.query_params.getlist('expand'),
|
||||
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
|
||||
pdf_data=self.request.query_params.get('pdf_data', 'false').lower() == 'true',
|
||||
questions_supported=s.validated_data['questions_supported'],
|
||||
use_order_locale=s.validated_data['use_order_locale'],
|
||||
canceled_supported=True,
|
||||
request=self.request, # this is not clean, but we need it in the serializers for URL generation
|
||||
legacy_url_support=False,
|
||||
@@ -984,9 +994,9 @@ class CheckinRPCSearchView(ListAPIView):
|
||||
def get_queryset(self, ignore_status=False, ignore_products=False):
|
||||
qs = _checkin_list_position_queryset(
|
||||
self.lists,
|
||||
ignore_status=self.request.query_params.get('ignore_status', 'false') == 'true' or ignore_status,
|
||||
ignore_status=self.request.query_params.get('ignore_status', 'false').lower() == 'true' or ignore_status,
|
||||
ignore_products=ignore_products,
|
||||
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
|
||||
pdf_data=self.request.query_params.get('pdf_data', 'false').lower() == 'true',
|
||||
expand=self.request.query_params.getlist('expand'),
|
||||
)
|
||||
|
||||
@@ -994,3 +1004,79 @@ class CheckinRPCSearchView(ListAPIView):
|
||||
qs = qs.none()
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class CheckinRPCAnnulView(views.APIView):
|
||||
def post(self, request, *args, **kwargs):
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders'))
|
||||
elif self.request.user.is_authenticated:
|
||||
events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
else:
|
||||
raise ValueError("unknown authentication method")
|
||||
|
||||
s = CheckinRPCAnnulInputSerializer(data=request.data, context={'events': events})
|
||||
s.is_valid(raise_exception=True)
|
||||
|
||||
with transaction.atomic():
|
||||
try:
|
||||
qs = Checkin.all.all()
|
||||
if isinstance(request.auth, Device):
|
||||
qs = qs.filter(device=request.auth)
|
||||
ci = qs.select_for_update(
|
||||
of=OF_SELF,
|
||||
).select_related("position", "position__order", "position__order__event").get(
|
||||
list__in=s.validated_data['lists'],
|
||||
nonce=s.validated_data['nonce'],
|
||||
)
|
||||
if connection.features.has_select_for_update_of and ci.position_id:
|
||||
# Lock position as well, can't do it with of= above because relation is nullable
|
||||
OrderPosition.objects.select_for_update(of=OF_SELF).get(pk=ci.position_id)
|
||||
|
||||
if not ci.successful or not ci.position:
|
||||
raise ValidationError("Cannot annul an unsuccessful checkin")
|
||||
except Checkin.DoesNotExist:
|
||||
raise NotFound("No check-in found based on nonce")
|
||||
except Checkin.MultipleObjectsReturned:
|
||||
raise ValidationError("Multiple check-ins found based on nonce")
|
||||
|
||||
annulment_time = s.validated_data.get("datetime") or now()
|
||||
|
||||
if annulment_time - ci.datetime > timedelta(minutes=15):
|
||||
# Compare to sent datetime, which makes this cheatable, but allows offline annulment of checkins
|
||||
ci.position.order.log_action('pretix.event.checkin.annulment.ignored', data={
|
||||
'checkin': ci.pk,
|
||||
'position': ci.position.id,
|
||||
'positionid': ci.position.positionid,
|
||||
'datetime': annulment_time,
|
||||
'error_explanation': s.validated_data.get("error_explanation"),
|
||||
'type': ci.type,
|
||||
'list': ci.list_id,
|
||||
}, user=request.user, auth=request.auth)
|
||||
return Response({
|
||||
"non_field_errors": ["Annulment is not allowed more than 15 minutes after check-in"]
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if ci.device and ci.device != request.auth:
|
||||
return Response({
|
||||
"non_field_errors": ["Annulment is only allowed from the same device"]
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
ci.successful = False
|
||||
ci.error_reason = Checkin.REASON_ANNULLED
|
||||
ci.error_explanation = s.validated_data.get("error_explanation")
|
||||
ci.save(update_fields=["successful", "error_reason", "error_explanation"])
|
||||
ci.position.order.log_action('pretix.event.checkin.annulled', data={
|
||||
'checkin': ci.pk,
|
||||
'position': ci.position.id,
|
||||
'positionid': ci.position.positionid,
|
||||
'datetime': annulment_time,
|
||||
'error_explanation': s.validated_data.get("error_explanation"),
|
||||
'type': ci.type,
|
||||
'list': ci.list_id,
|
||||
}, user=request.user, auth=request.auth)
|
||||
checkin_annulled.send(ci.position.order.event, checkin=ci)
|
||||
|
||||
return Response({"status": "ok"}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -580,6 +580,11 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx["event"] = self.request.event
|
||||
return ctx
|
||||
|
||||
|
||||
class ItemMetaPropertiesViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ItemMetaPropertiesSerializer
|
||||
|
||||
@@ -485,8 +485,17 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
|
||||
pass
|
||||
|
||||
|
||||
with scopes_disabled():
|
||||
class QuotaFilter(FilterSet):
|
||||
items__in = NumberInFilter(
|
||||
field_name='items__id',
|
||||
lookup_expr='in',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Quota
|
||||
fields = {
|
||||
@@ -508,7 +517,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
return self.request.event.quotas.all()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
queryset = self.filter_queryset(self.get_queryset()).distinct()
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ from pretix.api.serializers.order import (
|
||||
BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer,
|
||||
OrderPaymentCreateSerializer, OrderPaymentSerializer,
|
||||
OrderPositionSerializer, OrderRefundCreateSerializer,
|
||||
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer,
|
||||
PrintLogSerializer, RevokedTicketSecretSerializer,
|
||||
SimulatedOrderSerializer,
|
||||
OrderRefundSerializer, OrderSerializer, OrganizerTransactionSerializer,
|
||||
PriceCalcSerializer, PrintLogSerializer, RevokedTicketSecretSerializer,
|
||||
SimulatedOrderSerializer, TransactionSerializer,
|
||||
)
|
||||
from pretix.api.serializers.orderchange import (
|
||||
BlockNameSerializer, OrderChangeOperationSerializer,
|
||||
@@ -80,6 +80,7 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, PrintLog, QuestionAnswer, RevokedTicketSecret,
|
||||
Transaction,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.pdf import get_images
|
||||
@@ -87,7 +88,7 @@ from pretix.base.secrets import assign_ticket_secret
|
||||
from pretix.base.services import tickets
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
||||
regenerate_invoice,
|
||||
regenerate_invoice, transmit_invoice,
|
||||
)
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.orders import (
|
||||
@@ -185,7 +186,7 @@ with scopes_disabled():
|
||||
| Q(full_invoice_no__iexact=u)
|
||||
).values_list('order_id', flat=True)
|
||||
|
||||
matching_positions = OrderPosition.objects.filter(
|
||||
matching_positions = OrderPosition.all.filter(
|
||||
Q(order=OuterRef('pk')) & Q(
|
||||
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
|
||||
| Q(secret__istartswith=u)
|
||||
@@ -227,7 +228,7 @@ class OrderViewSetMixin:
|
||||
def get_queryset(self):
|
||||
qs = self.get_base_queryset()
|
||||
if 'fees' not in self.request.GET.getlist('exclude'):
|
||||
if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
|
||||
if self.request.query_params.get('include_canceled_fees', 'false').lower() == 'true':
|
||||
fqs = OrderFee.all
|
||||
else:
|
||||
fqs = OrderFee.objects
|
||||
@@ -245,11 +246,11 @@ class OrderViewSetMixin:
|
||||
return qs
|
||||
|
||||
def _positions_prefetch(self, request):
|
||||
if request.query_params.get('include_canceled_positions', 'false') == 'true':
|
||||
if request.query_params.get('include_canceled_positions', 'false').lower() == 'true':
|
||||
opq = OrderPosition.all
|
||||
else:
|
||||
opq = OrderPosition.objects
|
||||
if request.query_params.get('pdf_data', 'false') == 'true' and getattr(request, 'event', None):
|
||||
if request.query_params.get('pdf_data', 'false').lower() == 'true' and getattr(request, 'event', None):
|
||||
prefetch_related_objects([request.organizer], 'meta_properties')
|
||||
prefetch_related_objects(
|
||||
[request.event],
|
||||
@@ -343,7 +344,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
|
||||
return ctx
|
||||
|
||||
def get_base_queryset(self):
|
||||
@@ -452,10 +453,9 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
comment = request.data.get('comment', None)
|
||||
cancellation_fee = request.data.get('cancellation_fee', None)
|
||||
if cancellation_fee:
|
||||
try:
|
||||
cancellation_fee = float(Decimal(cancellation_fee))
|
||||
except:
|
||||
cancellation_fee = None
|
||||
cancellation_fee = serializers.DecimalField(max_digits=13, decimal_places=2).to_internal_value(
|
||||
cancellation_fee,
|
||||
)
|
||||
|
||||
order = self.get_object()
|
||||
if not order.cancel_allowed():
|
||||
@@ -743,7 +743,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth,
|
||||
)
|
||||
order_placed.send(self.request.event, order=order)
|
||||
order_placed.send(self.request.event, order=order, bulk=False)
|
||||
if order.status == Order.STATUS_PAID:
|
||||
order_paid.send(self.request.event, order=order)
|
||||
order.log_action(
|
||||
@@ -943,6 +943,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
@action(detail=True, methods=['POST'])
|
||||
def change(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
check_quotas = self.request.query_params.get('check_quotas', 'true').lower() == 'true'
|
||||
|
||||
serializer = OrderChangeOperationSerializer(
|
||||
context={'order': order, **self.get_serializer_context()},
|
||||
@@ -1008,7 +1009,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
elif serializer.validated_data.get('recalculate_taxes') == 'keep_gross':
|
||||
ocm.recalculate_taxes(keep='gross')
|
||||
|
||||
ocm.commit()
|
||||
ocm.commit(check_quotas=check_quotas)
|
||||
except OrderError as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
@@ -1086,17 +1087,18 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
|
||||
ctx['check_quotas'] = self.request.query_params.get('check_quotas', 'true').lower() == 'true'
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
|
||||
if self.request.query_params.get('include_canceled_positions', 'false').lower() == 'true':
|
||||
qs = OrderPosition.all
|
||||
else:
|
||||
qs = OrderPosition.objects
|
||||
|
||||
qs = qs.filter(order__event=self.request.event)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
if self.request.query_params.get('pdf_data', 'false').lower() == 'true':
|
||||
prefetch_related_objects([self.request.organizer], 'meta_properties')
|
||||
prefetch_related_objects(
|
||||
[self.request.event],
|
||||
@@ -1889,6 +1891,12 @@ class RetryException(APIException):
|
||||
default_code = 'retry_later'
|
||||
|
||||
|
||||
class CurrentlyInflightException(APIException):
|
||||
status_code = 409
|
||||
default_detail = 'The requested action is already in progress.'
|
||||
default_code = 'currently_inflight'
|
||||
|
||||
|
||||
class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = InvoiceSerializer
|
||||
queryset = Invoice.objects.none()
|
||||
@@ -1937,13 +1945,52 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
|
||||
return resp
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def transmit(self, request, **kwargs):
|
||||
invoice = self.get_object()
|
||||
if invoice.shredded:
|
||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||
|
||||
if invoice.transmission_status != Invoice.TRANSMISSION_STATUS_PENDING:
|
||||
raise PermissionDenied('The invoice is not in pending state.')
|
||||
|
||||
transmit_invoice.apply_async(args=(self.request.event.pk, invoice.pk, False))
|
||||
return Response(status=204)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def retransmit(self, request, **kwargs):
|
||||
invoice = self.get_object()
|
||||
if invoice.shredded:
|
||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||
|
||||
with transaction.atomic(durable=True):
|
||||
invoice = Invoice.objects.select_for_update(of=OF_SELF).get(pk=invoice.pk)
|
||||
|
||||
if invoice.transmission_status == Invoice.TRANSMISSION_STATUS_INFLIGHT:
|
||||
raise CurrentlyInflightException()
|
||||
|
||||
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING
|
||||
invoice.transmission_date = now()
|
||||
invoice.save(update_fields=["transmission_status", "transmission_date"])
|
||||
invoice.order.log_action(
|
||||
'pretix.event.order.invoice.retransmitted',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'invoice': invoice.pk,
|
||||
'full_invoice_no': invoice.full_invoice_no,
|
||||
}
|
||||
)
|
||||
transmit_invoice.apply_async(args=(self.request.event.pk, invoice.pk, True))
|
||||
return Response(status=204)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def regenerate(self, request, **kwargs):
|
||||
inv = self.get_object()
|
||||
if inv.canceled:
|
||||
raise ValidationError('The invoice has already been canceled.')
|
||||
if not inv.event.settings.invoice_regenerate_allowed:
|
||||
raise PermissionDenied('Invoices may not be changed after they are created.')
|
||||
if not inv.regenerate_allowed:
|
||||
raise PermissionDenied('Invoice may not be regenerated.')
|
||||
elif inv.shredded:
|
||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||
elif inv.sent_to_organizer:
|
||||
@@ -2031,3 +2078,61 @@ class BlockedSecretViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
return BlockedTicketSecret.objects.filter(event=self.request.event)
|
||||
|
||||
|
||||
with scopes_disabled():
|
||||
class TransactionFilter(FilterSet):
|
||||
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
|
||||
event = django_filters.CharFilter(field_name='order__event', lookup_expr='slug__iexact')
|
||||
datetime_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
|
||||
datetime_before = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='lt')
|
||||
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
|
||||
created_before = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='lt')
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = {
|
||||
'item': ['exact', 'in'],
|
||||
'variation': ['exact', 'in'],
|
||||
'subevent': ['exact', 'in'],
|
||||
'tax_rule': ['exact', 'in'],
|
||||
'tax_code': ['exact', 'in'],
|
||||
'tax_rate': ['exact', 'in'],
|
||||
'fee_type': ['exact', 'in'],
|
||||
}
|
||||
|
||||
|
||||
class TransactionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = TransactionSerializer
|
||||
queryset = Transaction.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
ordering = ('datetime', 'pk')
|
||||
ordering_fields = ('datetime', 'created', 'id',)
|
||||
filterset_class = TransactionFilter
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
return Transaction.objects.filter(order__event=self.request.event).select_related("order")
|
||||
|
||||
|
||||
class OrganizerTransactionViewSet(TransactionViewSet):
|
||||
serializer_class = OrganizerTransactionSerializer
|
||||
permission = None
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Transaction.objects.filter(
|
||||
order__event__organizer=self.request.organizer
|
||||
).select_related("order", "order__event")
|
||||
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
qs = qs.filter(
|
||||
order__event__in=self.request.auth.get_events_with_permission("can_view_orders"),
|
||||
)
|
||||
elif self.request.user.is_authenticated:
|
||||
qs = qs.filter(
|
||||
order__event__in=self.request.user.get_events_with_permission("can_view_orders", request=self.request)
|
||||
)
|
||||
else:
|
||||
raise PermissionDenied("Unknown authentication scheme")
|
||||
|
||||
return qs
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
# 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 operator
|
||||
from decimal import Decimal
|
||||
from functools import reduce
|
||||
|
||||
import django_filters
|
||||
from django.contrib.auth.hashers import make_password
|
||||
@@ -48,15 +50,18 @@ from pretix.api.serializers.organizer import (
|
||||
TeamInviteSerializer, TeamMemberSerializer, TeamSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Customer, Device, GiftCard, GiftCardTransaction, Membership,
|
||||
MembershipType, Organizer, SalesChannel, SeatingPlan, Team, TeamAPIToken,
|
||||
TeamInvite, User,
|
||||
Customer, Device, Event, GiftCard, GiftCardTransaction, LogEntry,
|
||||
Membership, MembershipType, Organizer, SalesChannel, SeatingPlan, Team,
|
||||
TeamAPIToken, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.plugins import (
|
||||
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
|
||||
)
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
|
||||
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrganizerSerializer
|
||||
queryset = Organizer.objects.none()
|
||||
lookup_field = 'slug'
|
||||
@@ -65,6 +70,7 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
filter_backends = (TotalOrderingFilter,)
|
||||
ordering = ('slug',)
|
||||
ordering_fields = ('name', 'slug')
|
||||
write_permission = "can_change_organizer_settings"
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_authenticated:
|
||||
@@ -83,6 +89,67 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
else:
|
||||
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
original_data = self.get_serializer(instance=serializer.instance).data
|
||||
|
||||
current_plugins_value = serializer.instance.get_plugins()
|
||||
updated_plugins_value = serializer.validated_data.get('plugins', None)
|
||||
|
||||
super().perform_update(serializer)
|
||||
|
||||
if serializer.data == original_data:
|
||||
# Performance optimization: If nothing was changed, we do not need to save or log anything.
|
||||
# This costs us a few cycles on save, but avoids thousands of lines in our log.
|
||||
return
|
||||
|
||||
if updated_plugins_value is not None and set(updated_plugins_value) != set(current_plugins_value):
|
||||
enabled = {m: 'enabled' for m in updated_plugins_value if m not in current_plugins_value}
|
||||
disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value}
|
||||
changed = merge_dicts(enabled, disabled)
|
||||
|
||||
plugins_available = {
|
||||
p.module: p
|
||||
for p in get_all_plugins(organizer=serializer.instance)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
qs = []
|
||||
for module in disabled:
|
||||
pluginmeta = plugins_available[module]
|
||||
level = getattr(pluginmeta, 'level', PLUGIN_LEVEL_EVENT)
|
||||
if level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID:
|
||||
qs.append(Q(plugins__regex='(^|,)' + module + '(,|$)'))
|
||||
|
||||
if qs:
|
||||
events_to_disable = set(self.request.organizer.events.filter(
|
||||
reduce(operator.or_, qs)
|
||||
).values_list("pk", flat=True))
|
||||
logentries_to_save = []
|
||||
events_to_save = []
|
||||
|
||||
for e in self.request.organizer.events.filter(pk__in=events_to_disable):
|
||||
for module in disabled:
|
||||
if module in e.get_plugins():
|
||||
logentries_to_save.append(
|
||||
e.log_action('pretix.event.plugins.disabled', user=self.request.user, auth=self.request.auth,
|
||||
data={'plugin': module}, save=False)
|
||||
)
|
||||
e.disable_plugin(module)
|
||||
events_to_save.append(e)
|
||||
|
||||
Event.objects.bulk_update(events_to_save, fields=["plugins"])
|
||||
LogEntry.objects.bulk_create(logentries_to_save)
|
||||
|
||||
for module, operation in changed.items():
|
||||
serializer.instance.log_action(
|
||||
'pretix.organizer.plugins.' + operation,
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'plugin': module}
|
||||
)
|
||||
|
||||
|
||||
class SeatingPlanViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = SeatingPlanSerializer
|
||||
@@ -479,7 +546,8 @@ class DeviceViewSet(mixins.CreateModelMixin,
|
||||
|
||||
|
||||
class OrganizerSettingsView(views.APIView):
|
||||
permission = 'can_change_organizer_settings'
|
||||
permission = None
|
||||
write_permission = 'can_change_organizer_settings'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
||||
|
||||
@@ -78,6 +78,13 @@ class WebhookEvent:
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@property
|
||||
def help_text(self) -> str:
|
||||
"""
|
||||
A human-readable description
|
||||
"""
|
||||
return ""
|
||||
|
||||
|
||||
def get_all_webhook_events():
|
||||
global _ALL_EVENTS
|
||||
@@ -97,9 +104,10 @@ def get_all_webhook_events():
|
||||
|
||||
|
||||
class ParametrizedWebhookEvent(WebhookEvent):
|
||||
def __init__(self, action_type, verbose_name):
|
||||
def __init__(self, action_type, verbose_name, help_text=""):
|
||||
self._action_type = action_type
|
||||
self._verbose_name = verbose_name
|
||||
self._help_text = help_text
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
@@ -110,6 +118,10 @@ class ParametrizedWebhookEvent(WebhookEvent):
|
||||
def verbose_name(self):
|
||||
return self._verbose_name
|
||||
|
||||
@property
|
||||
def help_text(self):
|
||||
return self._help_text
|
||||
|
||||
|
||||
class ParametrizedOrderWebhookEvent(ParametrizedWebhookEvent):
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
@@ -161,6 +173,19 @@ class ParametrizedEventWebhookEvent(ParametrizedWebhookEvent):
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
# do not use content_object, this is also called in deletion
|
||||
return {
|
||||
'notification_id': logentry.pk,
|
||||
'organizer': logentry.event.organizer.slug,
|
||||
'event': logentry.event.slug,
|
||||
'voucher': logentry.object_id,
|
||||
'action': logentry.action_type,
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedSubEventWebhookEvent(ParametrizedWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
@@ -346,8 +371,9 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
),
|
||||
ParametrizedItemWebhookEvent(
|
||||
'pretix.event.item.*',
|
||||
_('Product changed (including product added or deleted and including changes to nested objects like '
|
||||
'variations or bundles)'),
|
||||
_('Product changed'),
|
||||
_('This includes product added or deleted and changes to nested objects like '
|
||||
'variations or bundles.'),
|
||||
),
|
||||
ParametrizedEventWebhookEvent(
|
||||
'pretix.event.live.activated',
|
||||
@@ -381,6 +407,19 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
'pretix.event.orders.waitinglist.voucher_assigned',
|
||||
_('Waiting list entry received voucher'),
|
||||
),
|
||||
ParametrizedVoucherWebhookEvent(
|
||||
'pretix.voucher.added',
|
||||
_('Voucher added'),
|
||||
),
|
||||
ParametrizedVoucherWebhookEvent(
|
||||
'pretix.voucher.changed',
|
||||
_('Voucher changed'),
|
||||
_('Only includes explicit changes to the voucher, not e.g. an increase of the number of redemptions.')
|
||||
),
|
||||
ParametrizedVoucherWebhookEvent(
|
||||
'pretix.voucher.deleted',
|
||||
_('Voucher deleted'),
|
||||
),
|
||||
ParametrizedCustomerWebhookEvent(
|
||||
'pretix.customer.created',
|
||||
_('Customer account created'),
|
||||
@@ -476,7 +515,7 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int, retr
|
||||
300, # + 5 minutes
|
||||
1200, # + 20 minutes
|
||||
3600, # + 60 minutes
|
||||
1440, # + 4 hours
|
||||
14400, # + 4 hours
|
||||
21600, # + 6 hours
|
||||
43200, # + 12 hours
|
||||
43200, # + 24 hours
|
||||
@@ -527,8 +566,10 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int, retr
|
||||
if retry_count >= len(retry_intervals):
|
||||
return 'retry-given-up'
|
||||
elif retry_intervals[retry_count] < retry_celery_cutoff:
|
||||
send_webhook.apply_async(args=(logentry_id, action_type, webhook_id, retry_count + 1),
|
||||
countdown=retry_intervals[retry_count])
|
||||
send_webhook.apply_async(
|
||||
args=(logentry_id, action_type, webhook_id, retry_count + 1),
|
||||
countdown=retry_intervals[retry_count]
|
||||
)
|
||||
return 'retry-via-celery'
|
||||
else:
|
||||
webhook.retries.update_or_create(
|
||||
@@ -555,7 +596,10 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int, retr
|
||||
if retry_count >= len(retry_intervals):
|
||||
return 'retry-given-up'
|
||||
elif retry_intervals[retry_count] < retry_celery_cutoff:
|
||||
send_webhook.apply_async(args=(logentry_id, action_type, webhook_id, retry_count + 1))
|
||||
send_webhook.apply_async(
|
||||
args=(logentry_id, action_type, webhook_id, retry_count + 1),
|
||||
countdown=retry_intervals[retry_count]
|
||||
)
|
||||
return 'retry-via-celery'
|
||||
else:
|
||||
webhook.retries.update_or_create(
|
||||
|
||||
@@ -43,10 +43,10 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import exporter # NOQA
|
||||
from . import payment # NOQA
|
||||
from . import exporters # NOQA
|
||||
from . import invoice # NOQA
|
||||
from .invoicing import pdf, transmission, email, peppol, national # NOQA
|
||||
from . import notifications # NOQA
|
||||
from . import email # NOQA
|
||||
from .services import auth, checkin, currencies, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .services import auth, checkin, currencies, datasync, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .models import _transactions # NOQA
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -35,19 +35,22 @@ def get_powered_by(request, safelink=True):
|
||||
d = gs.settings.license_check_input
|
||||
if d.get('poweredby_name'):
|
||||
if d.get('poweredby_url'):
|
||||
n = '<a href="{}" target="_blank" rel="noopener">{}</a>'.format(
|
||||
sl(d['poweredby_url']) if safelink else d['poweredby_url'],
|
||||
d['poweredby_name']
|
||||
msg = gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>').format(
|
||||
name=d['poweredby_name'],
|
||||
a_name_attr='href="{}" target="_blank" rel="noopener"'.format(
|
||||
sl(d['poweredby_url']) if safelink else d['poweredby_url'],
|
||||
),
|
||||
a_attr='href="{}" target="_blank" rel="noopener"'.format(
|
||||
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
|
||||
)
|
||||
)
|
||||
else:
|
||||
n = d['poweredby_name']
|
||||
|
||||
msg = gettext('powered by {name} based on <a {a_attr}>pretix</a>').format(
|
||||
name=n,
|
||||
a_attr='href="{}" target="_blank" rel="noopener"'.format(
|
||||
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
|
||||
msg = gettext('<a {a_attr}>powered by {name} based on pretix</a>').format(
|
||||
name=d['poweredby_name'],
|
||||
a_attr='href="{}" target="_blank" rel="noopener"'.format(
|
||||
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
msg = gettext('<a %(a_attr)s>ticketing powered by pretix</a>') % {
|
||||
'a_attr': 'href="{}" target="_blank" rel="noopener"'.format(
|
||||
@@ -71,7 +74,7 @@ def contextprocessor(request):
|
||||
try:
|
||||
ctx['poweredby'] = get_powered_by(request, safelink=True)
|
||||
except Exception:
|
||||
ctx['poweredby'] = 'powered by <a href="https://pretix.eu/" target="_blank" rel="noopener">pretix</a>'
|
||||
ctx['poweredby'] = '<a href="https://pretix.eu/" target="_blank" rel="noopener">powered by pretix</a>'
|
||||
if settings.DEBUG and 'runserver' not in sys.argv:
|
||||
ctx['debug_warning'] = True
|
||||
elif 'runserver' in sys.argv:
|
||||
|
||||
@@ -199,6 +199,7 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
|
||||
params['client_id'] = provider.configuration['client_id']
|
||||
params['client_secret'] = provider.configuration['client_secret']
|
||||
|
||||
resp = None
|
||||
try:
|
||||
resp = requests.post(
|
||||
endpoint,
|
||||
@@ -214,7 +215,10 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except RequestException:
|
||||
logger.exception('Could not retrieve authorization token')
|
||||
if resp:
|
||||
logger.exception(f'Could not retrieve authorization token. Response: {resp.text}')
|
||||
else:
|
||||
logger.exception('Could not retrieve authorization token')
|
||||
raise ValidationError(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='could not reach login provider',
|
||||
@@ -222,6 +226,7 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
|
||||
)
|
||||
|
||||
if 'access_token' not in data:
|
||||
logger.error(f'Could not find access token. Response: {data}')
|
||||
raise ValidationError(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='access token missing',
|
||||
@@ -229,6 +234,7 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
|
||||
)
|
||||
|
||||
endpoint = provider.configuration['provider_config']['userinfo_endpoint']
|
||||
resp = None
|
||||
try:
|
||||
# https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
|
||||
resp = requests.get(
|
||||
@@ -240,7 +246,10 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
|
||||
resp.raise_for_status()
|
||||
userinfo = resp.json()
|
||||
except RequestException:
|
||||
logger.exception('Could not retrieve user info')
|
||||
if resp:
|
||||
logger.exception(f'Could not retrieve user info. Response: {resp.text}')
|
||||
else:
|
||||
logger.exception('Could not retrieve user info')
|
||||
raise ValidationError(
|
||||
_('Login was not successful. Error message: "{error}".').format(
|
||||
error='could not fetch user info',
|
||||
|
||||
21
src/pretix/base/datasync/__init__.py
Normal file
21
src/pretix/base/datasync/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
450
src/pretix/base/datasync/datasync.py
Normal file
450
src/pretix/base/datasync/datasync.py
Normal file
@@ -0,0 +1,450 @@
|
||||
#
|
||||
# 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 json
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
from datetime import timedelta
|
||||
from functools import cached_property
|
||||
from typing import List, Optional, Protocol
|
||||
|
||||
import sentry_sdk
|
||||
from django.db import DatabaseError, transaction
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.datasync.sourcefields import (
|
||||
EVENT, EVENT_OR_SUBEVENT, ORDER, ORDER_POSITION, get_data_fields,
|
||||
)
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.logentrytype_registry import make_link
|
||||
from pretix.base.models.datasync import OrderSyncQueue, OrderSyncResult
|
||||
from pretix.base.signals import PluginAwareRegistry
|
||||
from pretix.helpers import OF_SELF
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
datasync_providers = PluginAwareRegistry({"identifier": lambda o: o.identifier})
|
||||
|
||||
|
||||
class BaseSyncError(Exception):
|
||||
def __init__(self, messages, full_message=None):
|
||||
self.messages = messages
|
||||
self.full_message = full_message
|
||||
|
||||
|
||||
class UnrecoverableSyncError(BaseSyncError):
|
||||
"""
|
||||
A SyncProvider encountered a permanent problem, where a retry will not be successful.
|
||||
"""
|
||||
failure_mode = "permanent"
|
||||
|
||||
|
||||
class SyncConfigError(UnrecoverableSyncError):
|
||||
"""
|
||||
A SyncProvider is misconfigured in a way where a retry without configuration change will
|
||||
not be successful.
|
||||
"""
|
||||
failure_mode = "config"
|
||||
|
||||
|
||||
class RecoverableSyncError(BaseSyncError):
|
||||
"""
|
||||
A SyncProvider has encountered a temporary problem, and the sync should be retried
|
||||
at a later time.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ObjectMapping(Protocol):
|
||||
id: int
|
||||
pretix_model: str
|
||||
external_object_type: str
|
||||
pretix_id_field: str
|
||||
external_id_field: str
|
||||
property_mappings: str
|
||||
|
||||
|
||||
StaticMapping = namedtuple('StaticMapping', ('id', 'pretix_model', 'external_object_type', 'pretix_id_field', 'external_id_field', 'property_mappings'))
|
||||
|
||||
|
||||
class OutboundSyncProvider:
|
||||
max_attempts = 5
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def display_name(cls):
|
||||
return str(cls.identifier)
|
||||
|
||||
@classmethod
|
||||
def enqueue_order(cls, order, triggered_by, not_before=None, immediate=False):
|
||||
"""
|
||||
Adds an order to the sync queue. May only be called on derived classes which define an ``identifier`` attribute.
|
||||
|
||||
Should be called in the appropriate signal receivers, e.g.::
|
||||
|
||||
@receiver(order_placed, dispatch_uid="mysync_order_placed")
|
||||
def on_order_placed(sender, order, **kwargs):
|
||||
MySyncProvider.enqueue_order(order, "order_placed")
|
||||
|
||||
:param order: the Order that should be synced
|
||||
:param triggered_by: the reason why the order should be synced, e.g. name of the signal
|
||||
(currently only used internally for logging)
|
||||
:param immediate: whether a new sync task should run immediately for this order, instead
|
||||
of waiting for the next periodic_task interval
|
||||
:return: Return a tuple (queue_item, created), where created is a boolean
|
||||
specifying whether a new queue item was created.
|
||||
"""
|
||||
if not hasattr(cls, 'identifier'):
|
||||
raise TypeError('Call this method on a derived class that defines an "identifier" attribute.')
|
||||
queue_item, created = OrderSyncQueue.objects.update_or_create(
|
||||
order=order,
|
||||
sync_provider=cls.identifier,
|
||||
in_flight=False,
|
||||
defaults={
|
||||
"event": order.event,
|
||||
"triggered_by": triggered_by,
|
||||
"not_before": not_before or now(),
|
||||
"need_manual_retry": None,
|
||||
},
|
||||
)
|
||||
if immediate:
|
||||
from pretix.base.services.datasync import sync_single
|
||||
sync_single.apply_async(args=(queue_item.pk,))
|
||||
return queue_item, created
|
||||
|
||||
@classmethod
|
||||
def get_external_link_info(cls, event, external_link_href, external_link_display_name):
|
||||
return {
|
||||
"href": external_link_href,
|
||||
"val": external_link_display_name,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_external_link_html(cls, event, external_link_href, external_link_display_name):
|
||||
info = cls.get_external_link_info(event, external_link_href, external_link_display_name)
|
||||
return make_link(info, '{val}')
|
||||
|
||||
def next_retry_date(self, sq):
|
||||
"""
|
||||
Optionally override to configure a different retry backoff behavior
|
||||
"""
|
||||
return now() + timedelta(hours=1)
|
||||
|
||||
def should_sync_order(self, order):
|
||||
"""
|
||||
Optionally override this method to exclude certain orders from sync by returning ``False``
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def mappings(self):
|
||||
"""
|
||||
Implementations must override this property to provide the data mappings as a list of objects.
|
||||
|
||||
They can return instances of the ``StaticMapping`` `namedtuple` defined above, or create their own
|
||||
class (e.g. a Django model).
|
||||
|
||||
:return: The returned objects must have at least the following properties:
|
||||
|
||||
- `id`: Unique identifier for this mapping. If the mappings are Django models, the database primary key
|
||||
should be used. This may be referenced in other mappings, to establish relations between objects.
|
||||
- `pretix_model`: Which pretix model to use as data source in this mapping. Possible values are
|
||||
the keys of ``sourcefields.AVAILABLE_MODELS``
|
||||
- `external_object_type`: Destination object type in the target system. opaque string of maximum 128 characters.
|
||||
- `pretix_id_field`: Which pretix data field should be used to identify the mapped object. Any ``DataFieldInfo.key``
|
||||
returned by ``sourcefields.get_data_fields()`` for the combination of ``Event`` and ``pretix_model``.
|
||||
- `external_id_field`: Destination identifier field in the target system.
|
||||
- `property_mappings`: Mapping configuration as generated by ``PropertyMappingFormSet.to_property_mappings_list()``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def sync_queued_orders(self, queued_orders):
|
||||
"""
|
||||
This method should catch all Exceptions and handle them appropriately. It should never throw
|
||||
an Exception, as that may block the entire queue.
|
||||
"""
|
||||
for queue_item in queued_orders:
|
||||
with transaction.atomic():
|
||||
try:
|
||||
sq = (
|
||||
OrderSyncQueue.objects
|
||||
.select_for_update(of=OF_SELF, nowait=True)
|
||||
.select_related("order")
|
||||
.get(pk=queue_item.pk)
|
||||
)
|
||||
if sq.in_flight:
|
||||
continue
|
||||
sq.in_flight = True
|
||||
sq.in_flight_since = now()
|
||||
sq.save()
|
||||
except DatabaseError:
|
||||
# Either select_for_update failed to lock the row, or we couldn't set in_flight
|
||||
# as this order is already in flight (UNIQUE violation). In either case, we ignore
|
||||
# this order for now.
|
||||
continue
|
||||
|
||||
try:
|
||||
mapped_objects = self.sync_order(sq.order)
|
||||
if not all(all(not res or res.sync_info.get("action", "") == "nothing_to_do" for res in res_list) for res_list in mapped_objects.values()):
|
||||
sq.order.log_action("pretix.event.order.data_sync.success", {
|
||||
"provider": self.identifier,
|
||||
"objects": {
|
||||
mapping_id: [osr and osr.to_result_dict() for osr in results]
|
||||
for mapping_id, results in mapped_objects.items()
|
||||
},
|
||||
})
|
||||
sq.delete()
|
||||
except UnrecoverableSyncError as e:
|
||||
sq.set_sync_error(e.failure_mode, e.messages, e.full_message)
|
||||
except RecoverableSyncError as e:
|
||||
sq.failed_attempts += 1
|
||||
sq.not_before = self.next_retry_date(sq)
|
||||
# model changes saved by set_sync_error / clear_in_flight calls below
|
||||
if sq.failed_attempts >= self.max_attempts:
|
||||
logger.exception('Failed to sync order (max attempts exceeded)')
|
||||
sentry_sdk.capture_exception(e)
|
||||
sq.set_sync_error("exceeded", e.messages, e.full_message)
|
||||
else:
|
||||
logger.info(
|
||||
f"Could not sync order {sq.order.code} to {type(self).__name__} "
|
||||
f"(transient error, attempt #{sq.failed_attempts}, next {sq.not_before})",
|
||||
exc_info=True,
|
||||
)
|
||||
sq.clear_in_flight()
|
||||
except Exception as e:
|
||||
logger.exception('Failed to sync order (unhandled exception)')
|
||||
sentry_sdk.capture_exception(e)
|
||||
sq.set_sync_error("internal", [], str(e))
|
||||
|
||||
@cached_property
|
||||
def data_fields(self):
|
||||
return {
|
||||
f.key: f
|
||||
for f in get_data_fields(self.event)
|
||||
}
|
||||
|
||||
def get_field_value(self, inputs, mapping_entry):
|
||||
key = mapping_entry["pretix_field"]
|
||||
try:
|
||||
field = self.data_fields[key]
|
||||
except KeyError:
|
||||
with language(self.event.settings.locale):
|
||||
raise SyncConfigError([_(
|
||||
'Field "{field_name}" does not exist. Please check your {provider_name} settings.'
|
||||
).format(field_name=key, provider_name=self.display_name)])
|
||||
try:
|
||||
input = inputs[field.required_input]
|
||||
except KeyError:
|
||||
with language(self.event.settings.locale):
|
||||
raise SyncConfigError([_(
|
||||
'Field "{field_name}" requires {required_input}, but only got {available_inputs}. Please check your {provider_name} settings.'
|
||||
).format(field_name=key, required_input=field.required_input, available_inputs=", ".join(inputs.keys()), provider_name=self.display_name)])
|
||||
val = field.getter(input)
|
||||
if isinstance(val, list):
|
||||
if field.enum_opts and mapping_entry.get("value_map"):
|
||||
map = json.loads(mapping_entry["value_map"])
|
||||
try:
|
||||
val = [map[el] for el in val]
|
||||
except KeyError:
|
||||
with language(self.event.settings.locale):
|
||||
raise SyncConfigError([_(
|
||||
'Please update value mapping for field "{field_name}" - option "{val}" not assigned'
|
||||
).format(field_name=key, val=val)])
|
||||
|
||||
val = ",".join(val)
|
||||
return val
|
||||
|
||||
def get_properties(self, inputs: dict, property_mappings: List[dict]):
|
||||
return [
|
||||
(m["external_field"], self.get_field_value(inputs, m), m["overwrite"])
|
||||
for m in property_mappings
|
||||
]
|
||||
|
||||
def sync_object_with_properties(
|
||||
self,
|
||||
external_id_field: str,
|
||||
id_value,
|
||||
properties: list,
|
||||
inputs: dict,
|
||||
mapping: ObjectMapping,
|
||||
mapped_objects: dict,
|
||||
**kwargs,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
This method is called for each object that needs to be created/updated in the external system -- which these are is
|
||||
determined by the implementation of the `mapping` property.
|
||||
|
||||
:param external_id_field: Identifier field in the external system as provided in ``mapping.external_identifier``
|
||||
:param id_value: Identifier contents as retrieved from the property specified by ``mapping.pretix_identifier`` of the model
|
||||
specified by ``mapping.pretix_model``
|
||||
:param properties: All properties defined in ``mapping.property_mappings``, as list of three-tuples
|
||||
``(external_field, value, overwrite)``
|
||||
:param inputs: All pretix model instances from which data can be retrieved for this mapping.
|
||||
Dictionary mapping from sourcefields.ORDER_POSITION, .ORDER, .EVENT, .EVENT_OR_SUBEVENT to the
|
||||
relevant Django model.
|
||||
Most providers don't need to use this parameter directly, as `properties` and `id_value`
|
||||
already contain the values as evaluated from the available inputs.
|
||||
:param mapping: The mapping object as returned by ``self.mappings``
|
||||
:param mapped_objects: Information about objects that were synced in the same sync run, by mapping definitions
|
||||
*before* the current one in order of ``self.mappings``.
|
||||
Type is a dictionary ``{mapping.id: [list of OrderSyncResult objects]}``
|
||||
Useful to create associations between objects in the target system.
|
||||
|
||||
Example code to create return value::
|
||||
|
||||
return {
|
||||
# optional:
|
||||
"action": "nothing_to_do", # to inform that no action was taken, because the data was already up-to-date.
|
||||
# other values for action (e.g. create, update) currently have no special
|
||||
# meaning, but are visible for debugging purposes to admins.
|
||||
|
||||
# optional:
|
||||
"external_link_href": "https://external-system.example.com/backend/link/to/contact/123/",
|
||||
"external_link_display_name": "Contact #123 - Jane Doe",
|
||||
"...optionally further values you need in mapped_objects for association": 123456789,
|
||||
}
|
||||
|
||||
The return value needs to be a JSON serializable dict, or None.
|
||||
|
||||
Return None only in case you decide this object should not be synced at all in this mapping. Do not return None in
|
||||
case the object is already up-to-date in the target system (return "action": "nothing_to_do" instead).
|
||||
|
||||
This method needs to be idempotent, i.e. calling it multiple times with the same input values should create
|
||||
only a single object in the target system.
|
||||
|
||||
Subsequent calls with the same mapping and id_value should update the existing object, instead of creating a new one.
|
||||
In a SQL database, you might use an `INSERT OR UPDATE` or `UPSERT` statement; many REST APIs provide an equivalent API call.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def sync_object(
|
||||
self,
|
||||
inputs: dict,
|
||||
mapping,
|
||||
mapped_objects: dict,
|
||||
):
|
||||
logger.debug("Syncing object %r, %r, %r", inputs, mapping, mapped_objects)
|
||||
properties = self.get_properties(inputs, mapping.property_mappings)
|
||||
logger.debug("Properties: %r", properties)
|
||||
|
||||
id_value = self.get_field_value(inputs, {"pretix_field": mapping.pretix_id_field})
|
||||
if not id_value:
|
||||
return None
|
||||
|
||||
info = self.sync_object_with_properties(
|
||||
external_id_field=mapping.external_id_field,
|
||||
id_value=id_value,
|
||||
properties=properties,
|
||||
inputs=inputs,
|
||||
mapping=mapping,
|
||||
mapped_objects=mapped_objects,
|
||||
)
|
||||
if not info:
|
||||
return None
|
||||
external_link_href = info.pop('external_link_href', None)
|
||||
external_link_display_name = info.pop('external_link_display_name', None)
|
||||
obj, created = OrderSyncResult.objects.update_or_create(
|
||||
order=inputs.get(ORDER), order_position=inputs.get(ORDER_POSITION), sync_provider=self.identifier,
|
||||
mapping_id=mapping.id,
|
||||
defaults=dict(
|
||||
external_object_type=mapping.external_object_type,
|
||||
external_id_field=mapping.external_id_field,
|
||||
id_value=id_value,
|
||||
external_link_href=external_link_href,
|
||||
external_link_display_name=external_link_display_name,
|
||||
sync_info=info,
|
||||
transmitted=now(),
|
||||
)
|
||||
)
|
||||
return obj
|
||||
|
||||
def sync_order(self, order):
|
||||
if not self.should_sync_order(order):
|
||||
logger.debug("Skipping order %r", order)
|
||||
return {}
|
||||
|
||||
logger.debug("Syncing order %r", order)
|
||||
positions = list(
|
||||
order.all_positions
|
||||
.prefetch_related("answers", "answers__question")
|
||||
.select_related(
|
||||
"voucher",
|
||||
)
|
||||
)
|
||||
order_inputs = {ORDER: order, EVENT: self.event}
|
||||
mapped_objects = {}
|
||||
for mapping in self.mappings:
|
||||
if mapping.pretix_model == 'Order':
|
||||
mapped_objects[mapping.id] = [
|
||||
self.sync_object(order_inputs, mapping, mapped_objects)
|
||||
]
|
||||
elif mapping.pretix_model == 'OrderPosition':
|
||||
mapped_objects[mapping.id] = [
|
||||
self.sync_object({
|
||||
**order_inputs, EVENT_OR_SUBEVENT: op.subevent or self.event, ORDER_POSITION: op
|
||||
}, mapping, mapped_objects)
|
||||
for op in positions
|
||||
]
|
||||
else:
|
||||
raise SyncConfigError("Invalid pretix model '{}'".format(mapping.pretix_model))
|
||||
self.finalize_sync_order(order)
|
||||
return mapped_objects
|
||||
|
||||
def filter_mapped_objects(self, mapped_objects, inputs):
|
||||
"""
|
||||
For order positions, only
|
||||
"""
|
||||
if ORDER_POSITION in inputs:
|
||||
return {
|
||||
mapping_id: [
|
||||
osr for osr in results
|
||||
if osr and (osr.order_position_id is None or osr.order_position_id == inputs[ORDER_POSITION].id)
|
||||
]
|
||||
for mapping_id, results in mapped_objects.items()
|
||||
}
|
||||
else:
|
||||
return mapped_objects
|
||||
|
||||
def finalize_sync_order(self, order):
|
||||
"""
|
||||
Called after ``sync_object`` has been called successfully for all objects of a specific order. Can
|
||||
be used for saving bulk information per order.
|
||||
"""
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Called after all orders of an event have been synced. Can be used for clean-up tasks (e.g. closing
|
||||
a session).
|
||||
"""
|
||||
pass
|
||||
659
src/pretix/base/datasync/sourcefields.py
Normal file
659
src/pretix/base/datasync/sourcefields.py
Normal file
@@ -0,0 +1,659 @@
|
||||
#
|
||||
# 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 collections import namedtuple
|
||||
from functools import partial
|
||||
|
||||
from django.db.models import Max, Q
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.models import Checkin, InvoiceAddress, Order, Question
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
|
||||
def get_answer(op, question_identifier=None):
|
||||
a = None
|
||||
if op.addon_to:
|
||||
if "answers" in getattr(op.addon_to, "_prefetched_objects_cache", {}):
|
||||
try:
|
||||
a = [
|
||||
a
|
||||
for a in op.addon_to.answers.all()
|
||||
if a.question.identifier == question_identifier
|
||||
][0]
|
||||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
a = op.addon_to.answers.filter(
|
||||
question__identifier=question_identifier
|
||||
).first()
|
||||
|
||||
if "answers" in getattr(op, "_prefetched_objects_cache", {}):
|
||||
try:
|
||||
a = [
|
||||
a
|
||||
for a in op.answers.all()
|
||||
if a.question.identifier == question_identifier
|
||||
][0]
|
||||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
a = op.answers.filter(question__identifier=question_identifier).first()
|
||||
|
||||
if not a:
|
||||
return ""
|
||||
else:
|
||||
if a.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
return [str(o.identifier) for o in a.options.all()]
|
||||
if a.question.type == Question.TYPE_BOOLEAN:
|
||||
return a.answer == "True"
|
||||
return a.answer
|
||||
|
||||
|
||||
def get_payment_date(order):
|
||||
if order.status == Order.STATUS_PENDING:
|
||||
return None
|
||||
|
||||
return isoformat_or_none(order.payments.aggregate(m=Max("payment_date"))["m"])
|
||||
|
||||
|
||||
def isoformat_or_none(dt):
|
||||
return dt and dt.isoformat()
|
||||
|
||||
|
||||
def first_checkin_on_list(list_pk, position):
|
||||
checkin = position.checkins.filter(
|
||||
list__pk=list_pk, type=Checkin.TYPE_ENTRY
|
||||
).first()
|
||||
if checkin:
|
||||
return isoformat_or_none(checkin.datetime)
|
||||
|
||||
|
||||
def split_name_on_last_space(name, part):
|
||||
name_parts = name.rsplit(" ", 1)
|
||||
return name_parts[part] if len(name_parts) > part else ""
|
||||
|
||||
|
||||
def normalize_email(email):
|
||||
if email:
|
||||
local, host = email.split("@")
|
||||
host = host.encode("idna").decode()
|
||||
return f"{local}@{host}"
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def get_email_domain(email):
|
||||
if email:
|
||||
local, host = email.split("@")
|
||||
return host
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
ORDER_POSITION = 'position'
|
||||
ORDER = 'order'
|
||||
EVENT = 'event'
|
||||
EVENT_OR_SUBEVENT = 'event_or_subevent'
|
||||
AVAILABLE_MODELS = {
|
||||
'OrderPosition': (ORDER_POSITION, ORDER, EVENT_OR_SUBEVENT, EVENT),
|
||||
'Order': (ORDER, EVENT),
|
||||
}
|
||||
|
||||
DataFieldCategory = namedtuple(
|
||||
'DataFieldCategory',
|
||||
field_names=('sort_index', 'label',),
|
||||
)
|
||||
|
||||
CAT_ORDER_POSITION = DataFieldCategory(10, _('Order position details'))
|
||||
CAT_ATTENDEE = DataFieldCategory(11, _('Attendee details'))
|
||||
CAT_QUESTIONS = DataFieldCategory(12, _('Questions'))
|
||||
CAT_PRODUCT = DataFieldCategory(20, _('Product details'))
|
||||
CAT_ORDER = DataFieldCategory(21, _('Order details'))
|
||||
CAT_INVOICE_ADDRESS = DataFieldCategory(22, _('Invoice address'))
|
||||
CAT_EVENT = DataFieldCategory(30, _('Event information'))
|
||||
CAT_EVENT_OR_SUBEVENT = DataFieldCategory(31, pgettext_lazy('subevent', 'Event or date information'))
|
||||
|
||||
DataFieldInfo = namedtuple(
|
||||
'DataFieldInfo',
|
||||
field_names=('required_input', 'category', 'key', 'label', 'type', 'enum_opts', 'getter', 'deprecated'),
|
||||
defaults=[False]
|
||||
)
|
||||
|
||||
|
||||
def get_invoice_address_or_empty(order):
|
||||
try:
|
||||
return order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
return InvoiceAddress()
|
||||
|
||||
|
||||
def get_data_fields(event, for_model=None):
|
||||
"""
|
||||
Returns tuple of (required_input, key, label, type, enum_opts, getter)
|
||||
|
||||
Type is one of the Question types as defined in Question.TYPE_CHOICES.
|
||||
|
||||
The data type of the return value of `getter` depends on `type`:
|
||||
- TYPE_CHOICE_MULTIPLE: list of strings
|
||||
- TYPE_CHOICE: list, containing zero or one strings
|
||||
- TYPE_BOOLEAN: boolean
|
||||
- all other (including TYPE_NUMBER): string
|
||||
"""
|
||||
name_scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
name_headers = []
|
||||
if name_scheme and len(name_scheme["fields"]) > 1:
|
||||
for k, label, w in name_scheme["fields"]:
|
||||
name_headers.append(label)
|
||||
|
||||
src_fields = (
|
||||
[
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ATTENDEE,
|
||||
"attendee_name",
|
||||
_("Attendee name"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.attendee_name
|
||||
or (position.addon_to.attendee_name if position.addon_to else None),
|
||||
),
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ATTENDEE,
|
||||
"attendee_name_" + k,
|
||||
_("Attendee") + ": " + label,
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
partial(
|
||||
lambda k, position: (
|
||||
position.attendee_name_parts
|
||||
or (position.addon_to.attendee_name_parts if position.addon_to else {})
|
||||
or {}
|
||||
).get(k, ""),
|
||||
k,
|
||||
),
|
||||
deprecated=len(name_scheme["fields"]) == 1,
|
||||
)
|
||||
for k, label, w in name_scheme["fields"]
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ATTENDEE,
|
||||
"attendee_email",
|
||||
_("Attendee email"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: normalize_email(
|
||||
position.attendee_email
|
||||
or (position.addon_to.attendee_email if position.addon_to else None)
|
||||
),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ATTENDEE,
|
||||
"attendee_or_order_email",
|
||||
_("Attendee or order email"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: normalize_email(
|
||||
position.attendee_email
|
||||
or (position.addon_to.attendee_email if position.addon_to else None)
|
||||
or position.order.email
|
||||
),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ATTENDEE,
|
||||
"attendee_company",
|
||||
_("Attendee company"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.company or (position.addon_to.company if position.addon_to else None),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ATTENDEE,
|
||||
"attendee_street",
|
||||
_("Attendee address street"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.street or (position.addon_to.street if position.addon_to else None),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ATTENDEE,
|
||||
"attendee_zipcode",
|
||||
_("Attendee address ZIP code"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.zipcode or (position.addon_to.zipcode if position.addon_to else None),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ATTENDEE,
|
||||
"attendee_city",
|
||||
_("Attendee address city"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.city or (position.addon_to.city if position.addon_to else None),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ATTENDEE,
|
||||
"attendee_country",
|
||||
_("Attendee address country"),
|
||||
Question.TYPE_COUNTRYCODE,
|
||||
None,
|
||||
lambda position: str(
|
||||
position.country or (position.addon_to.attendee_name if position.addon_to else "")
|
||||
),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_INVOICE_ADDRESS,
|
||||
"invoice_address_company",
|
||||
_("Invoice address company"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: get_invoice_address_or_empty(order).company,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_INVOICE_ADDRESS,
|
||||
"invoice_address_name",
|
||||
_("Invoice address name"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: get_invoice_address_or_empty(order).name,
|
||||
),
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_INVOICE_ADDRESS,
|
||||
"invoice_address_name_" + k,
|
||||
_("Invoice address") + ": " + label,
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
partial(
|
||||
lambda k, order: (get_invoice_address_or_empty(order).name_parts or {}).get(
|
||||
k, ""
|
||||
),
|
||||
k,
|
||||
),
|
||||
deprecated=len(name_scheme["fields"]) == 1,
|
||||
)
|
||||
for k, label, w in name_scheme["fields"]
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_INVOICE_ADDRESS,
|
||||
"invoice_address_street",
|
||||
_("Invoice address street"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: get_invoice_address_or_empty(order).street,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_INVOICE_ADDRESS,
|
||||
"invoice_address_zipcode",
|
||||
_("Invoice address ZIP code"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: get_invoice_address_or_empty(order).zipcode,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_INVOICE_ADDRESS,
|
||||
"invoice_address_city",
|
||||
_("Invoice address city"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: get_invoice_address_or_empty(order).city,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_INVOICE_ADDRESS,
|
||||
"invoice_address_country",
|
||||
_("Invoice address country"),
|
||||
Question.TYPE_COUNTRYCODE,
|
||||
None,
|
||||
lambda order: str(get_invoice_address_or_empty(order).country),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_ORDER,
|
||||
"email",
|
||||
_("Order email"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: normalize_email(order.email),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_ORDER,
|
||||
"email_domain",
|
||||
_("Order email domain"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: get_email_domain(normalize_email(order.email)),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_ORDER,
|
||||
"order_code",
|
||||
_("Order code"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: order.code,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_ORDER,
|
||||
"event_order_code",
|
||||
_("Event and order code"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: order.full_code,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_ORDER,
|
||||
"order_total",
|
||||
_("Order total"),
|
||||
Question.TYPE_NUMBER,
|
||||
None,
|
||||
lambda order: str(order.total),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_PRODUCT,
|
||||
"product",
|
||||
_("Product and variation name"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: str(
|
||||
str(position.item.internal_name or position.item.name)
|
||||
+ ((" – " + str(position.variation.value)) if position.variation else "")
|
||||
),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_PRODUCT,
|
||||
"product_id",
|
||||
_("Product ID"),
|
||||
Question.TYPE_NUMBER,
|
||||
None,
|
||||
lambda position: str(position.item.pk),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_PRODUCT,
|
||||
"product_is_admission",
|
||||
_("Product is admission product"),
|
||||
Question.TYPE_BOOLEAN,
|
||||
None,
|
||||
lambda position: bool(position.item.admission),
|
||||
),
|
||||
DataFieldInfo(
|
||||
EVENT,
|
||||
CAT_EVENT,
|
||||
"event_slug",
|
||||
_("Event short form"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda event: str(event.slug),
|
||||
),
|
||||
DataFieldInfo(
|
||||
EVENT,
|
||||
CAT_EVENT,
|
||||
"event_name",
|
||||
_("Event name"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda event: str(event.name),
|
||||
),
|
||||
DataFieldInfo(
|
||||
EVENT_OR_SUBEVENT,
|
||||
CAT_EVENT_OR_SUBEVENT,
|
||||
"event_date_from",
|
||||
_("Event start date"),
|
||||
Question.TYPE_DATETIME,
|
||||
None,
|
||||
lambda event_or_subevent: isoformat_or_none(event_or_subevent.date_from),
|
||||
),
|
||||
DataFieldInfo(
|
||||
EVENT_OR_SUBEVENT,
|
||||
CAT_EVENT_OR_SUBEVENT,
|
||||
"event_date_to",
|
||||
_("Event end date"),
|
||||
Question.TYPE_DATETIME,
|
||||
None,
|
||||
lambda event_or_subevent: isoformat_or_none(event_or_subevent.date_to),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ORDER_POSITION,
|
||||
"voucher_code",
|
||||
_("Voucher code"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.voucher.code if position.voucher_id else "",
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ORDER_POSITION,
|
||||
"ticket_id",
|
||||
_("Order code and position number"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: position.code,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ORDER_POSITION,
|
||||
"ticket_price",
|
||||
_("Ticket price"),
|
||||
Question.TYPE_NUMBER,
|
||||
None,
|
||||
lambda position: str(position.price),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_ORDER,
|
||||
"order_status",
|
||||
_("Order status"),
|
||||
Question.TYPE_CHOICE,
|
||||
Order.STATUS_CHOICE,
|
||||
lambda order: [order.status],
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ORDER_POSITION,
|
||||
"ticket_status",
|
||||
_("Ticket status"),
|
||||
Question.TYPE_CHOICE,
|
||||
Order.STATUS_CHOICE,
|
||||
lambda position: [Order.STATUS_CANCELED if position.canceled else position.order.status],
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_ORDER,
|
||||
"order_date",
|
||||
_("Order date and time"),
|
||||
Question.TYPE_DATETIME,
|
||||
None,
|
||||
lambda order: order.datetime.isoformat(),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_ORDER,
|
||||
"payment_date",
|
||||
_("Payment date and time"),
|
||||
Question.TYPE_DATETIME,
|
||||
None,
|
||||
get_payment_date,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_ORDER,
|
||||
"order_locale",
|
||||
_("Order locale"),
|
||||
Question.TYPE_CHOICE,
|
||||
[(lc, lc) for lc in event.settings.locales],
|
||||
lambda order: [order.locale],
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ORDER_POSITION,
|
||||
"position_id",
|
||||
_("Order position ID"),
|
||||
Question.TYPE_NUMBER,
|
||||
None,
|
||||
lambda op: str(op.pk),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_ORDER,
|
||||
"presale_order_url",
|
||||
_("Order link"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
),
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ORDER_POSITION,
|
||||
"presale_ticket_url",
|
||||
_("Ticket link"),
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda op: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.position', kwargs={
|
||||
'order': op.order.code,
|
||||
'secret': op.web_secret,
|
||||
'position': op.positionid
|
||||
}
|
||||
),
|
||||
),
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ORDER_POSITION,
|
||||
"checkin_date_" + str(cl.pk),
|
||||
_("Check-in datetime on list {}").format(cl.name),
|
||||
Question.TYPE_DATETIME,
|
||||
None,
|
||||
partial(first_checkin_on_list, cl.pk),
|
||||
)
|
||||
for cl in event.checkin_lists.all()
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_QUESTIONS,
|
||||
"question_" + q.identifier,
|
||||
_("Question: {name}").format(name=str(q.question)),
|
||||
q.type,
|
||||
get_enum_opts(q),
|
||||
partial(lambda qq, position: get_answer(position, qq.identifier), q),
|
||||
)
|
||||
for q in event.questions.filter(~Q(type=Question.TYPE_FILE)).prefetch_related("options")
|
||||
]
|
||||
)
|
||||
if not any(field_name == "given_name" for field_name, label, weight in name_scheme["fields"]):
|
||||
src_fields += [
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ATTENDEE,
|
||||
"attendee_name_given_name",
|
||||
_("Attendee") + ": " + _("Given name") + " (⚠️ auto-generated, not recommended)",
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: split_name_on_last_space(position.attendee_name, part=0),
|
||||
deprecated=True,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_INVOICE_ADDRESS,
|
||||
"invoice_address_name_given_name",
|
||||
_("Invoice address") + ": " + _("Given name") + " (⚠️ auto-generated, not recommended)",
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: split_name_on_last_space(get_invoice_address_or_empty(order).name, part=0),
|
||||
deprecated=True,
|
||||
),
|
||||
]
|
||||
|
||||
if not any(field_name == "family_name" for field_name, label, weight in name_scheme["fields"]):
|
||||
src_fields += [
|
||||
DataFieldInfo(
|
||||
ORDER_POSITION,
|
||||
CAT_ATTENDEE,
|
||||
"attendee_name_family_name",
|
||||
_("Attendee") + ": " + _("Family name") + " (⚠️ auto-generated, not recommended)",
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda position: split_name_on_last_space(position.attendee_name, part=1),
|
||||
deprecated=True,
|
||||
),
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_INVOICE_ADDRESS,
|
||||
"invoice_address_name_family_name",
|
||||
_("Invoice address") + ": " + _("Family name") + " (⚠️ auto-generated, not recommended)",
|
||||
Question.TYPE_STRING,
|
||||
None,
|
||||
lambda order: split_name_on_last_space(get_invoice_address_or_empty(order).name, part=1),
|
||||
deprecated=True,
|
||||
),
|
||||
]
|
||||
|
||||
if for_model:
|
||||
available_inputs = AVAILABLE_MODELS[for_model]
|
||||
return [
|
||||
f for f in src_fields if f.required_input in available_inputs
|
||||
]
|
||||
else:
|
||||
return src_fields
|
||||
|
||||
|
||||
def get_enum_opts(q):
|
||||
if q.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
return [(opt.identifier, opt.answer) for opt in q.options.all()]
|
||||
else:
|
||||
return None
|
||||
123
src/pretix/base/datasync/utils.py
Normal file
123
src/pretix/base/datasync/utils.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#
|
||||
# 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 typing import List, Tuple
|
||||
|
||||
from pretix.base.datasync.datasync import SyncConfigError
|
||||
from pretix.base.models.datasync import (
|
||||
MODE_APPEND_LIST, MODE_OVERWRITE, MODE_SET_IF_EMPTY, MODE_SET_IF_NEW,
|
||||
)
|
||||
|
||||
|
||||
def assign_properties(
|
||||
new_values: List[Tuple[str, str, str]], old_values: dict, is_new, list_sep
|
||||
):
|
||||
"""
|
||||
Generates a dictionary mapping property keys to new values, handling conditional overwrites and list updates
|
||||
according to an update mode specified per property.
|
||||
|
||||
Supported update modes are:
|
||||
- `MODE_OVERWRITE`: Replaces the existing value with the new value.
|
||||
- `MODE_SET_IF_NEW`: Only sets the property if `is_new` is True.
|
||||
- `MODE_SET_IF_EMPTY`: Only sets the property if the field is empty or missing in old_values.
|
||||
- `MODE_APPEND_LIST`: Appends the new value to the list from old_values (or the empty list if missing),
|
||||
using `list_sep` as a separator.
|
||||
|
||||
:param new_values: List of tuples, where each tuple contains (field_name, new_value, update_mode).
|
||||
:param old_values: Dictionary, current property values in the external system.
|
||||
:param is_new: Boolean, whether the object will be newly created in the external system.
|
||||
:param list_sep: If string, used as a separator for MODE_APPEND_LIST. If None, native lists are used.
|
||||
:raises SyncConfigError: If an invalid update mode is specified.
|
||||
:returns: A dictionary containing the properties that need to be updated in the external system.
|
||||
"""
|
||||
|
||||
out = {}
|
||||
|
||||
for field_name, new_value, update_mode in new_values:
|
||||
if update_mode == MODE_OVERWRITE:
|
||||
out[field_name] = new_value
|
||||
continue
|
||||
elif update_mode == MODE_SET_IF_NEW and not is_new:
|
||||
continue
|
||||
if not new_value:
|
||||
continue
|
||||
|
||||
current_value = old_values.get(field_name, out.get(field_name, ""))
|
||||
if update_mode in (MODE_SET_IF_EMPTY, MODE_SET_IF_NEW):
|
||||
if not current_value:
|
||||
out[field_name] = new_value
|
||||
elif update_mode == MODE_APPEND_LIST:
|
||||
_add_to_list(out, field_name, current_value, new_value, list_sep)
|
||||
else:
|
||||
raise SyncConfigError(["Invalid update mode " + update_mode])
|
||||
return out
|
||||
|
||||
|
||||
def _add_to_list(out, field_name, current_value, new_item, list_sep):
|
||||
new_item = str(new_item)
|
||||
if list_sep is not None:
|
||||
new_item = new_item.replace(list_sep, "")
|
||||
current_value = current_value.split(list_sep) if current_value else []
|
||||
elif not isinstance(current_value, (list, tuple)):
|
||||
current_value = [str(current_value)]
|
||||
if new_item not in current_value:
|
||||
new_list = current_value + [new_item]
|
||||
if list_sep is not None:
|
||||
new_list = list_sep.join(new_list)
|
||||
out[field_name] = new_list
|
||||
|
||||
|
||||
def translate_property_mappings(property_mappings, checkin_list_map):
|
||||
"""
|
||||
To properly handle copied events, users of data fields as provided by get_data_fields need to register to the
|
||||
event_copy_data signal and translate all stored references to those fields using this method.
|
||||
|
||||
For example, if you store your mappings in a custom Django model with a ForeignKey to Event:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@receiver(signal=event_copy_data, dispatch_uid="my_sync_event_copy_data")
|
||||
def event_copy_data_receiver(sender, other, checkin_list_map, **kwargs):
|
||||
object_mappings = other.my_object_mappings.all()
|
||||
object_mapping_map = {}
|
||||
for om in object_mappings:
|
||||
om = copy.copy(om)
|
||||
object_mapping_map[om.pk] = om
|
||||
om.pk = None
|
||||
om.event = sender
|
||||
om.property_mappings = translate_property_mappings(om.property_mappings, checkin_list_map)
|
||||
om.save()
|
||||
|
||||
"""
|
||||
mappings = []
|
||||
|
||||
for mapping in property_mappings:
|
||||
pretix_field = mapping["pretix_field"]
|
||||
if pretix_field.startswith("checkin_date_"):
|
||||
old_id = int(pretix_field[len("checkin_date_"):])
|
||||
if old_id not in checkin_list_map:
|
||||
# old_id might not be in checkin_list_map, because copying of an event series only copies check-in
|
||||
# lists covering the whole series, not individual dates.
|
||||
pretix_field = "_invalid_" + pretix_field
|
||||
else:
|
||||
pretix_field = "checkin_date_%d" % checkin_list_map[old_id].pk
|
||||
mappings.append({**mapping, "pretix_field": pretix_field})
|
||||
return mappings
|
||||
@@ -68,6 +68,7 @@ from ...control.forms.filter import get_all_payment_providers
|
||||
from ...helpers import GroupConcat
|
||||
from ...helpers.iter import chunked_iterable
|
||||
from ...helpers.safe_openpyxl import remove_invalid_excel_chars
|
||||
from ...multidomain.urlreverse import build_absolute_uri
|
||||
from ..exporter import (
|
||||
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
|
||||
)
|
||||
@@ -287,6 +288,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('Email address verified'))
|
||||
headers.append(_('External customer ID'))
|
||||
headers.append(_('Payment providers'))
|
||||
headers.append(_('Order link'))
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_methods = self._get_all_payment_methods(qs)
|
||||
for id, vn in payment_methods:
|
||||
@@ -402,6 +404,13 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
if p and p != 'free'
|
||||
]))
|
||||
|
||||
row.append(
|
||||
build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
})
|
||||
)
|
||||
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_methods = self._get_all_payment_methods(qs)
|
||||
for id, vn in payment_methods:
|
||||
@@ -659,6 +668,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('External customer ID'),
|
||||
_('Check-in lists'),
|
||||
_('Payment providers'),
|
||||
_('Position order link')
|
||||
]
|
||||
|
||||
# get meta_data labels from first cached event
|
||||
@@ -712,7 +722,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
get_name_parts_localized(op.attendee_name_parts, k)
|
||||
get_name_parts_localized(op.attendee_name_parts, k) if op.attendee_name_parts else ''
|
||||
)
|
||||
row += [
|
||||
op.attendee_email,
|
||||
@@ -803,6 +813,14 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
if p and p != 'free'
|
||||
]))
|
||||
|
||||
row.append(
|
||||
build_absolute_uri(order.event, 'presale:event.order.position', kwargs={
|
||||
'order': order.code,
|
||||
'secret': op.web_secret,
|
||||
'position': op.positionid
|
||||
})
|
||||
)
|
||||
|
||||
if has_subevents:
|
||||
if op.subevent:
|
||||
row += op.subevent.meta_data.values()
|
||||
|
||||
@@ -108,8 +108,10 @@ class WaitingListExporter(ListExporter):
|
||||
_('Name'),
|
||||
_('Email'),
|
||||
_('Phone number'),
|
||||
_('Product name'),
|
||||
_('Product'),
|
||||
_('Product ID'),
|
||||
_('Variation'),
|
||||
_('Variation ID'),
|
||||
_('Event slug'),
|
||||
_('Event name'),
|
||||
pgettext_lazy('subevents', 'Date'), # Name of subevent
|
||||
@@ -146,7 +148,9 @@ class WaitingListExporter(ListExporter):
|
||||
entry.email,
|
||||
entry.phone,
|
||||
str(entry.item) if entry.item else "",
|
||||
str(entry.item.pk) if entry.item else "",
|
||||
str(entry.variation) if entry.variation else "",
|
||||
str(entry.variation.pk) if entry.variation else "",
|
||||
entry.event.slug,
|
||||
entry.event.name,
|
||||
entry.subevent.name if entry.subevent else "",
|
||||
|
||||
@@ -54,10 +54,10 @@ from django.core.validators import (
|
||||
from django.db.models import QuerySet
|
||||
from django.forms import Select, widgets
|
||||
from django.forms.widgets import FILE_INPUT_CONTRADICTION
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.text import format_lazy
|
||||
from django.utils.timezone import get_current_timezone
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries import countries
|
||||
@@ -77,6 +77,7 @@ from pretix.base.forms.widgets import (
|
||||
from pretix.base.i18n import (
|
||||
get_babel_locale, get_language_without_region, language,
|
||||
)
|
||||
from pretix.base.invoicing.transmission import get_transmission_types
|
||||
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
|
||||
from pretix.base.models.tax import ask_for_vat_id
|
||||
from pretix.base.services.tax import (
|
||||
@@ -181,10 +182,15 @@ class NamePartsWidget(forms.MultiWidget):
|
||||
if self.field.required:
|
||||
these_attrs['required'] = 'required'
|
||||
these_attrs.pop('data-no-required-attr', None)
|
||||
these_attrs['autocomplete'] = (self.attrs.get('autocomplete', '') + ' ' + self.autofill_map.get(self.scheme['fields'][i][0], 'off')).strip()
|
||||
|
||||
autofill_section = self.attrs.get('autocomplete', '')
|
||||
autofill_by_name_scheme = self.autofill_map.get(self.scheme['fields'][i][0], 'off')
|
||||
if autofill_by_name_scheme == "off" or autofill_section.strip() == "off":
|
||||
these_attrs['autocomplete'] = "off"
|
||||
else:
|
||||
these_attrs['autocomplete'] = (autofill_section + ' ' + autofill_by_name_scheme).strip()
|
||||
these_attrs['data-size'] = self.scheme['fields'][i][2]
|
||||
if len(self.widgets) > 1:
|
||||
these_attrs['aria-label'] = self.scheme['fields'][i][1]
|
||||
these_attrs['aria-label'] = self.scheme['fields'][i][1]
|
||||
else:
|
||||
these_attrs = final_attrs
|
||||
output.append(widget.render(name + '_%s' % i, widget_value, these_attrs, renderer=renderer))
|
||||
@@ -302,7 +308,10 @@ class WrappedPhonePrefixSelect(Select):
|
||||
self.initial = "+%d" % prefix
|
||||
break
|
||||
choices += get_phone_prefixes_sorted_and_localized()
|
||||
super().__init__(choices=choices, attrs={'aria-label': pgettext_lazy('phonenumber', 'International area code')})
|
||||
super().__init__(choices=choices, attrs={
|
||||
'aria-label': pgettext_lazy('phonenumber', 'International area code'),
|
||||
'autocomplete': 'tel-country-code',
|
||||
})
|
||||
|
||||
def render(self, name, value, *args, **kwargs):
|
||||
return super().render(name, value or self.initial, *args, **kwargs)
|
||||
@@ -325,11 +334,11 @@ class WrappedPhonePrefixSelect(Select):
|
||||
class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
||||
|
||||
def __init__(self, attrs=None, initial=None):
|
||||
attrs = {
|
||||
'aria-label': pgettext_lazy('phonenumber', 'Phone number (without international area code)')
|
||||
}
|
||||
widgets = (WrappedPhonePrefixSelect(initial), forms.TextInput(attrs=attrs))
|
||||
super(PhoneNumberPrefixWidget, self).__init__(widgets, attrs)
|
||||
widgets = (WrappedPhonePrefixSelect(initial), forms.TextInput(attrs={
|
||||
'aria-label': pgettext_lazy('phonenumber', 'Phone number (without international area code)'),
|
||||
'autocomplete': 'tel-national',
|
||||
}))
|
||||
super(PhoneNumberPrefixWidget, self).__init__(widgets)
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
output = super().render(name, value, attrs, renderer)
|
||||
@@ -727,7 +736,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial=country,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'country',
|
||||
'data-country-information-url': reverse('js_helpers.states'),
|
||||
'data-trigger-address-info': 'on',
|
||||
}),
|
||||
)
|
||||
c = [('', '---')]
|
||||
@@ -870,10 +879,34 @@ class BaseQuestionsForm(forms.Form):
|
||||
attrs['data-min'] = q.valid_date_min.isoformat()
|
||||
if q.valid_date_max:
|
||||
attrs['data-max'] = q.valid_date_max.isoformat()
|
||||
if not help_text:
|
||||
if q.valid_date_min and q.valid_date_max:
|
||||
help_text = format_lazy(
|
||||
'Please enter a date between {min} and {max}.',
|
||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
elif q.valid_date_min:
|
||||
help_text = format_lazy(
|
||||
'Please enter a date no earlier than {min}.',
|
||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
elif q.valid_date_max:
|
||||
help_text = format_lazy(
|
||||
'Please enter a date no later than {max}.',
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).date()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.DateField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
|
||||
initial=_initial,
|
||||
widget=DatePickerWidget(attrs),
|
||||
)
|
||||
if q.valid_date_min:
|
||||
@@ -881,17 +914,50 @@ class BaseQuestionsForm(forms.Form):
|
||||
if q.valid_date_max:
|
||||
field.validators.append(MaxDateValidator(q.valid_date_max))
|
||||
elif q.type == Question.TYPE_TIME:
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).time()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.TimeField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).time() if initial and initial.answer else None,
|
||||
initial=_initial,
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
if not help_text:
|
||||
if q.valid_datetime_min and q.valid_datetime_max:
|
||||
help_text = format_lazy(
|
||||
'Please enter a date and time between {min} and {max}.',
|
||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif q.valid_datetime_min:
|
||||
help_text = format_lazy(
|
||||
'Please enter a date and time no earlier than {min}.',
|
||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif q.valid_datetime_max:
|
||||
help_text = format_lazy(
|
||||
'Please enter a date and time no later than {max}.',
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).astimezone(tz)
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
|
||||
field = SplitDateTimeField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
|
||||
initial=_initial,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
min_date=q.valid_datetime_min,
|
||||
@@ -952,8 +1018,19 @@ class BaseQuestionsForm(forms.Form):
|
||||
value.initial = data.get('question_form_data', {}).get(key)
|
||||
|
||||
for k, v in self.fields.items():
|
||||
if isinstance(v.widget, forms.MultiWidget):
|
||||
for w in v.widget.widgets:
|
||||
autocomplete = w.attrs.get('autocomplete', '')
|
||||
if autocomplete.strip() == "off":
|
||||
w.attrs['autocomplete'] = 'off'
|
||||
else:
|
||||
w.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
|
||||
if v.widget.attrs.get('autocomplete') or k == 'attendee_name_parts':
|
||||
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + v.widget.attrs.get('autocomplete', '')
|
||||
autocomplete = v.widget.attrs.get('autocomplete', '')
|
||||
if autocomplete.strip() == "off":
|
||||
v.widget.attrs['autocomplete'] = 'off'
|
||||
else:
|
||||
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
|
||||
|
||||
def clean(self):
|
||||
from pretix.base.addressvalidation import \
|
||||
@@ -1065,11 +1142,19 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
if (not kwargs.get('instance') or not kwargs['instance'].country) and not kwargs["initial"].get("country"):
|
||||
kwargs['initial']['country'] = guess_country_from_request(self.request, self.event)
|
||||
|
||||
if kwargs.get('instance'):
|
||||
kwargs['initial'].update(kwargs['instance'].transmission_info or {})
|
||||
kwargs['initial']['transmission_type'] = kwargs['instance'].transmission_type
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Individuals do not have a company name or VAT ID
|
||||
self.fields["company"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]'
|
||||
self.fields["vat_id"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]'
|
||||
|
||||
# The internal reference is a very business-specific field and might confuse non-business users
|
||||
self.fields["internal_reference"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]'
|
||||
|
||||
if not self.ask_vat_id:
|
||||
del self.fields['vat_id']
|
||||
elif self.validate_vat_id:
|
||||
@@ -1085,8 +1170,17 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
|
||||
])
|
||||
|
||||
transmission_type_choices = [
|
||||
(t.identifier, t.public_name) for t in get_transmission_types()
|
||||
]
|
||||
if not self.address_required or self.all_optional:
|
||||
transmission_type_choices.insert(0, ("-", _("No invoice requested")))
|
||||
self.fields['transmission_type'] = forms.ChoiceField(
|
||||
label=_('Invoice transmission method'),
|
||||
choices=transmission_type_choices
|
||||
)
|
||||
|
||||
self.fields['country'].choices = CachedCountries()
|
||||
self.fields['country'].widget.attrs['data-country-information-url'] = reverse('js_helpers.states')
|
||||
|
||||
c = [('', '---')]
|
||||
fprefix = self.prefix + '-' if self.prefix else ''
|
||||
@@ -1165,9 +1259,55 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
else:
|
||||
del self.fields['custom_field']
|
||||
|
||||
# Add transmission type specific fields
|
||||
for transmission_type in get_transmission_types():
|
||||
for k, f in transmission_type.invoice_address_form_fields.items():
|
||||
if (
|
||||
transmission_type.identifier == "email" and
|
||||
k in ("transmission_email_other", "transmission_email_address") and
|
||||
(
|
||||
event.settings.invoice_generate == "False" or
|
||||
not event.settings.invoice_email_attachment
|
||||
)
|
||||
):
|
||||
# This looks like a very unclean hack (and probably really is one), but hear me out:
|
||||
# With pretix 2025.7, we introduced invoice transmission types and added the "send to another email"
|
||||
# feature for the email provider. This feature was previously part of the bank transfer payment
|
||||
# provider and opt-in. With this change, this feature becomes available for all pretix shops, which
|
||||
# we think is a good thing in the long run as it is an useful feature for every business customer.
|
||||
# However, there's two scenarios where it might be bad that we add it without opt-in:
|
||||
# - When the organizer has turned off invoice generation in pretix and is collecting invoice information
|
||||
# only for other reasons or to later create invoices with a separate software. In this case it
|
||||
# would be very bad for the user to be able to ask for the invoice to be sent somewhere else, and
|
||||
# that information then be ignored because the organizer has not updated their process.
|
||||
# - When the organizer has intentionally turned off invoices being attached to emails, because that
|
||||
# would somehow be a contradiction.
|
||||
# Now, the obvious solution would be to make the TransmissionType.invoice_address_form_fields property
|
||||
# a function that depends on the event as an input. However, I believe this is the wrong approach
|
||||
# over the long term. As a generalized concept, we DO want invoice address collection to be
|
||||
# *independent* of event settings, in order to (later) e.g. implement invoice address editing within
|
||||
# customer accounts. Hence, this hack directly in the form to provide (some) backwards compatibility
|
||||
# only for the default transmission type "email".
|
||||
continue
|
||||
|
||||
self.fields[k] = f
|
||||
f._required = f.required
|
||||
f.required = False
|
||||
f.widget.is_required = False
|
||||
if 'required' in f.widget.attrs:
|
||||
del f.widget.attrs['required']
|
||||
|
||||
for k, v in self.fields.items():
|
||||
if v.widget.attrs.get('autocomplete') or k == 'name_parts':
|
||||
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + v.widget.attrs.get('autocomplete', '')
|
||||
autocomplete = v.widget.attrs.get('autocomplete', '')
|
||||
if autocomplete.strip() == "off":
|
||||
v.widget.attrs['autocomplete'] = 'off'
|
||||
else:
|
||||
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + autocomplete
|
||||
|
||||
self.fields['country'].widget.attrs['data-trigger-address-info'] = 'on'
|
||||
self.fields['is_business'].widget.attrs['data-trigger-address-info'] = 'on'
|
||||
self.fields['transmission_type'].widget.attrs['data-trigger-address-info'] = 'on'
|
||||
|
||||
def clean(self):
|
||||
from pretix.base.addressvalidation import \
|
||||
@@ -1196,11 +1336,23 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
|
||||
self.instance.name_parts = data.get('name_parts')
|
||||
|
||||
if all(
|
||||
not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts')
|
||||
) and name_parts_is_empty(data.get('name_parts', {})):
|
||||
form_is_empty = all(
|
||||
not v for k, v in data.items()
|
||||
if k not in ('is_business', 'country', 'name_parts', 'transmission_type') and not k.startswith("transmission_")
|
||||
) and name_parts_is_empty(data.get('name_parts', {}))
|
||||
|
||||
if form_is_empty:
|
||||
# Do not save the country if it is the only field set -- we don't know the user even checked it!
|
||||
self.cleaned_data['country'] = ''
|
||||
if data.get('transmission_type') == "-":
|
||||
data['transmission_type'] = 'email' # our actual default for now, we can revisit this later
|
||||
|
||||
else:
|
||||
if data.get('transmission_type') == "-":
|
||||
raise ValidationError(
|
||||
{"transmission_type": _("If you enter an invoice address, you also need to select an invoice "
|
||||
"transmission method.")}
|
||||
)
|
||||
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass
|
||||
@@ -1222,6 +1374,37 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
else:
|
||||
self.instance.vat_id_validated = False
|
||||
|
||||
for transmission_type in get_transmission_types():
|
||||
if transmission_type.identifier == data.get("transmission_type"):
|
||||
if not transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
|
||||
raise ValidationError({
|
||||
"transmission_type": _("The selected transmission type is not available in your country or for "
|
||||
"your type of address.")
|
||||
})
|
||||
|
||||
required_fields = transmission_type.invoice_address_form_fields_required(data.get("country"), data.get("is_business"))
|
||||
for r in required_fields:
|
||||
if r not in self.fields:
|
||||
logger.info(f"Transmission type {transmission_type.identifier} required field {r} which is not available.")
|
||||
raise ValidationError(
|
||||
_("The selected type of invoice transmission requires a field that is currently not "
|
||||
"available, please reach out to the organizer.")
|
||||
)
|
||||
if not data.get(r):
|
||||
raise ValidationError({r: _("This field is required for the selected type of invoice transmission.")})
|
||||
|
||||
self.instance.transmission_type = transmission_type.identifier
|
||||
self.instance.transmission_info = {
|
||||
k: data.get(k) for k in transmission_type.invoice_address_form_fields
|
||||
}
|
||||
elif transmission_type.exclusive:
|
||||
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
|
||||
raise ValidationError({
|
||||
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
|
||||
transmission_type.public_name,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
class BaseInvoiceNameForm(BaseInvoiceAddressForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
21
src/pretix/base/invoicing/__init__.py
Normal file
21
src/pretix/base/invoicing/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
173
src/pretix/base/invoicing/email.py
Normal file
173
src/pretix/base/invoicing/email.py
Normal file
@@ -0,0 +1,173 @@
|
||||
#
|
||||
# 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 forms
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import Country
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.invoicing.transmission import (
|
||||
TransmissionProvider, TransmissionType, transmission_providers,
|
||||
transmission_types,
|
||||
)
|
||||
from pretix.base.models import Invoice, InvoiceAddress
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
from pretix.helpers.format import format_map
|
||||
|
||||
|
||||
@transmission_types.new()
|
||||
class EmailTransmissionType(TransmissionType):
|
||||
identifier = "email"
|
||||
verbose_name = _("Email")
|
||||
priority = 1000
|
||||
|
||||
@property
|
||||
def invoice_address_form_fields(self) -> dict:
|
||||
return {
|
||||
"transmission_email_other": forms.BooleanField(
|
||||
label=_("Email invoice directly to accounting department"),
|
||||
help_text=_("If not selected, the invoice will be sent to you using the email address listed above."),
|
||||
required=False,
|
||||
),
|
||||
"transmission_email_address": forms.EmailField(
|
||||
label=_("Email address for invoice"),
|
||||
widget=forms.EmailInput(
|
||||
attrs={"data-display-dependency": "#id_transmission_email_other"}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def invoice_address_form_fields_visible(self, country: Country, is_business: bool):
|
||||
if is_business:
|
||||
# We don't want ask non-business users if they have an accounting department ;)
|
||||
return {"transmission_email_other", "transmission_email_address"}
|
||||
return set()
|
||||
|
||||
def is_available(self, event, country: Country, is_business: bool):
|
||||
# Skip availability check since provider is always available and we do not want to end up without invoice
|
||||
# transmission type
|
||||
return True
|
||||
|
||||
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
|
||||
return {
|
||||
"transmission_email_other": bool(transmission_info.get("transmission_email_address")),
|
||||
"transmission_email_address": transmission_info.get("transmission_email_address"),
|
||||
}
|
||||
|
||||
def form_data_to_transmission_info(self, form_data: dict) -> dict:
|
||||
if form_data.get("transmission_email_other") and form_data.get("transmission_email_address"):
|
||||
return {
|
||||
"transmission_email_address": form_data["transmission_email_address"],
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@transmission_providers.new()
|
||||
class EmailTransmissionProvider(TransmissionProvider):
|
||||
identifier = "email_pdf"
|
||||
type = "email"
|
||||
verbose_name = _("PDF via email")
|
||||
priority = 1000
|
||||
testmode_supported = True
|
||||
|
||||
def is_ready(self, event) -> bool:
|
||||
return True
|
||||
|
||||
def is_available(self, event, country: Country, is_business: bool) -> bool:
|
||||
return True
|
||||
|
||||
def transmit(self, invoice: Invoice):
|
||||
info = (invoice.invoice_to_transmission_info or {})
|
||||
if info.get("transmission_email_address"):
|
||||
recipient = info["transmission_email_address"]
|
||||
else:
|
||||
recipient = invoice.order.email
|
||||
|
||||
if not recipient:
|
||||
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
|
||||
invoice.transmission_date = now()
|
||||
invoice.save(update_fields=["transmission_status", "transmission_date"])
|
||||
invoice.order.log_action(
|
||||
"pretix.event.order.invoice.sending_failed",
|
||||
data={
|
||||
"full_invoice_no": invoice.full_invoice_no,
|
||||
"transmission_provider": "email_pdf",
|
||||
"transmission_type": "email",
|
||||
"data": {
|
||||
"reason": "no_recipient",
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
with language(invoice.order.locale, invoice.order.event.settings.region):
|
||||
context = get_email_context(
|
||||
event=invoice.order.event,
|
||||
order=invoice.order,
|
||||
invoice=invoice,
|
||||
event_or_subevent=invoice.order.event,
|
||||
invoice_address=getattr(invoice.order, 'invoice_address', None) or InvoiceAddress()
|
||||
)
|
||||
template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString)
|
||||
subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString)
|
||||
|
||||
try:
|
||||
# Do not set to completed because that is done by the email sending task
|
||||
subject = format_map(subject, context)
|
||||
email_content = render_mail(template, context)
|
||||
mail(
|
||||
[recipient],
|
||||
subject,
|
||||
template,
|
||||
context=context,
|
||||
event=invoice.order.event,
|
||||
locale=invoice.order.locale,
|
||||
order=invoice.order,
|
||||
invoices=[invoice],
|
||||
attach_tickets=False,
|
||||
auto_email=True,
|
||||
attach_ical=False,
|
||||
plain_text_only=True,
|
||||
no_order_links=True,
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
else:
|
||||
invoice.order.log_action(
|
||||
'pretix.event.order.email.invoice',
|
||||
user=None,
|
||||
auth=None,
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'position': None,
|
||||
'recipient': recipient,
|
||||
'invoices': [invoice.pk],
|
||||
'attach_tickets': False,
|
||||
'attach_ical': False,
|
||||
'attach_other_files': [],
|
||||
'attach_cached_files': [],
|
||||
}
|
||||
)
|
||||
84
src/pretix/base/invoicing/national.py
Normal file
84
src/pretix/base/invoicing/national.py
Normal file
@@ -0,0 +1,84 @@
|
||||
#
|
||||
# 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 forms
|
||||
from django.core.validators import RegexValidator
|
||||
from django.utils.translation import pgettext, pgettext_lazy
|
||||
from django_countries.fields import Country
|
||||
from localflavor.it.forms import ITSocialSecurityNumberField
|
||||
|
||||
from pretix.base.invoicing.transmission import (
|
||||
TransmissionType, transmission_types,
|
||||
)
|
||||
|
||||
|
||||
@transmission_types.new()
|
||||
class ItalianSdITransmissionType(TransmissionType):
|
||||
identifier = "it_sdi"
|
||||
verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)")
|
||||
public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)")
|
||||
exclusive = True
|
||||
enforce_transmission = True
|
||||
|
||||
def is_available(self, event, country: Country, is_business: bool):
|
||||
return str(country) == "IT" and super().is_available(event, country, is_business)
|
||||
|
||||
@property
|
||||
def invoice_address_form_fields(self) -> dict:
|
||||
return {
|
||||
"transmission_it_sdi_codice_fiscale": ITSocialSecurityNumberField(
|
||||
label=pgettext_lazy("italian_invoice", "Fiscal code"),
|
||||
required=False,
|
||||
),
|
||||
"transmission_it_sdi_pec": forms.EmailField(
|
||||
label=pgettext_lazy("italian_invoice", "Address for certified electronic mail"),
|
||||
widget=forms.EmailInput()
|
||||
),
|
||||
"transmission_it_sdi_recipient_code": forms.CharField(
|
||||
label=pgettext_lazy("italian_invoice", "Recipient code"),
|
||||
validators=[
|
||||
RegexValidator("^[A-Z0-9]{6,7}$")
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
def invoice_address_form_fields_visible(self, country: Country, is_business: bool):
|
||||
if is_business:
|
||||
return {"transmission_it_sdi_codice_fiscale", "transmission_it_sdi_pec", "transmission_it_sdi_recipient_code"}
|
||||
return {"transmission_it_sdi_codice_fiscale", "transmission_it_sdi_pec"}
|
||||
|
||||
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
|
||||
base = {
|
||||
"street", "zipcode", "city", "state", "country",
|
||||
}
|
||||
if is_business:
|
||||
return base | {"company", "vat_id", "transmission_it_sdi_pec", "transmission_it_sdi_recipient_code"}
|
||||
return base | {"transmission_it_sdi_codice_fiscale"}
|
||||
|
||||
def pdf_info_text(self) -> str:
|
||||
# Watermark is not necessary as this is a usual precaution in Italy
|
||||
return pgettext(
|
||||
"italian_invoice",
|
||||
"This PDF document is a visual copy of the invoice and does not constitute an invoice for VAT "
|
||||
"purposes. The invoice is issued in XML format, transmitted in accordance with the procedures and terms "
|
||||
"set forth in No. 89757/2018 of April 30, 2018, issued by the Director of the Revenue Agency."
|
||||
)
|
||||
1217
src/pretix/base/invoicing/pdf.py
Normal file
1217
src/pretix/base/invoicing/pdf.py
Normal file
File diff suppressed because it is too large
Load Diff
177
src/pretix/base/invoicing/peppol.py
Normal file
177
src/pretix/base/invoicing/peppol.py
Normal file
@@ -0,0 +1,177 @@
|
||||
#
|
||||
# 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 re
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _, pgettext
|
||||
from django_countries.fields import Country
|
||||
|
||||
from pretix.base.invoicing.transmission import (
|
||||
TransmissionType, transmission_types,
|
||||
)
|
||||
|
||||
|
||||
class PeppolIdValidator:
|
||||
regex_rules = {
|
||||
# Source: https://docs.peppol.eu/edelivery/codelists/old/v8.5/Peppol%20Code%20Lists%20-%20Participant%20identifier%20schemes%20v8.5.html
|
||||
"0002": "[0-9]{9}([0-9]{5})?",
|
||||
"0007": "[0-9]{10}",
|
||||
"0009": "[0-9]{14}",
|
||||
"0037": "(0037)?[0-9]{7}-?[0-9][0-9A-Z]{0,5}",
|
||||
"0060": "[0-9]{9}",
|
||||
"0088": "[0-9]{13}",
|
||||
"0096": "[0-9]{17}",
|
||||
"0097": "[0-9]{11,16}",
|
||||
"0106": "[0-9]{17}",
|
||||
"0130": ".*",
|
||||
"0135": ".*",
|
||||
"0142": ".*",
|
||||
"0151": "[0-9]{11}",
|
||||
"0183": "CHE[0-9]{9}",
|
||||
"0184": "DK[0-9]{8}([0-9]{2})?",
|
||||
"0188": ".*",
|
||||
"0190": "[0-9]{20}",
|
||||
"0191": "[1789][0-9]{7}",
|
||||
"0192": "[0-9]{9}",
|
||||
"0193": ".{4,50}",
|
||||
"0195": "[a-z]{2}[a-z]{3}([0-9]{8}|[0-9]{9}|[RST][0-9]{2}[a-z]{2}[0-9]{4})[0-9a-z]",
|
||||
"0196": "[0-9]{10}",
|
||||
"0198": "DK[0-9]{8}",
|
||||
"0199": "[A-Z0-9]{18}[0-9]{2}",
|
||||
"0020": "[0-9]{9}",
|
||||
"0201": "[0-9a-zA-Z]{6}",
|
||||
"0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}",
|
||||
"0208": "0[0-9]{9}",
|
||||
"0209": ".*",
|
||||
"0210": "[A-Z0-9]+",
|
||||
"0211": "IT[0-9]{11}",
|
||||
"0212": "[0-9]{7}-[0-9]",
|
||||
"0213": "FI[0-9]{8}",
|
||||
"0205": "[A-Z0-9]+",
|
||||
"0221": "T[0-9]{13}",
|
||||
"0230": ".*",
|
||||
"9901": ".*",
|
||||
"9902": "[1-9][0-9]{7}",
|
||||
"9904": "DK[0-9]{8}",
|
||||
"9909": "NO[0-9]{9}MVA",
|
||||
"9910": "HU[0-9]{8}",
|
||||
"9912": "[A-Z]{2}[A-Z0-9]{,20}",
|
||||
"9913": ".*",
|
||||
"9914": "ATU[0-9]*",
|
||||
"9915": "[A-Z][A-Z0-9]*",
|
||||
"9916": ".*",
|
||||
"9917": "[0-9]{10}",
|
||||
"9918": "[A-Z]{2}[0-9]{2}[A-Z-0-9]{11,30}",
|
||||
"9919": "[A-Z][0-9]{3}[A-Z][0-9]{3}[A-Z]",
|
||||
"9920": ".*",
|
||||
"9921": ".*",
|
||||
"9922": ".*",
|
||||
"9923": ".*",
|
||||
"9924": ".*",
|
||||
"9925": ".*",
|
||||
"9926": ".*",
|
||||
"9927": ".*",
|
||||
"9928": ".*",
|
||||
"9929": ".*",
|
||||
"9930": ".*",
|
||||
"9931": ".*",
|
||||
"9932": ".*",
|
||||
"9933": ".*",
|
||||
"9934": ".*",
|
||||
"9935": ".*",
|
||||
"9936": ".*",
|
||||
"9937": ".*",
|
||||
"9938": ".*",
|
||||
"9939": ".*",
|
||||
"9940": ".*",
|
||||
"9941": ".*",
|
||||
"9942": ".*",
|
||||
"9943": ".*",
|
||||
"9944": ".*",
|
||||
"9945": ".*",
|
||||
"9946": ".*",
|
||||
"9947": ".*",
|
||||
"9948": ".*",
|
||||
"9949": ".*",
|
||||
"9950": ".*",
|
||||
"9951": ".*",
|
||||
"9952": ".*",
|
||||
"9953": ".*",
|
||||
"9954": ".*",
|
||||
"9956": "0[0-9]{9}",
|
||||
"9957": ".*",
|
||||
"9959": ".*",
|
||||
}
|
||||
|
||||
def __call__(self, value):
|
||||
if ":" not in value:
|
||||
raise ValidationError(_("A Peppol participant ID always starts with a prefix, followed by a colon (:)."))
|
||||
|
||||
prefix, second = value.split(":", 1)
|
||||
if prefix not in self.regex_rules:
|
||||
raise ValidationError(_("The Peppol participant ID prefix %(number)s is not known to our system. Please "
|
||||
"reach out to us if you are sure this ID is correct."), params={"number": prefix})
|
||||
|
||||
if not re.match(self.regex_rules[prefix], second):
|
||||
raise ValidationError(_("The Peppol participant ID does not match the validation rules for the prefix "
|
||||
"%(number)s. Please reach out to us if you are sure this ID is correct."),
|
||||
params={"number": prefix})
|
||||
return value
|
||||
|
||||
|
||||
@transmission_types.new()
|
||||
class PeppolTransmissionType(TransmissionType):
|
||||
identifier = "peppol"
|
||||
verbose_name = "Peppol"
|
||||
priority = 250
|
||||
enforce_transmission = True
|
||||
|
||||
def is_available(self, event, country: Country, is_business: bool):
|
||||
return is_business and super().is_available(event, country, is_business)
|
||||
|
||||
@property
|
||||
def invoice_address_form_fields(self) -> dict:
|
||||
return {
|
||||
"transmission_peppol_participant_id": forms.CharField(
|
||||
label=_("Peppol participant ID"),
|
||||
validators=[
|
||||
PeppolIdValidator(),
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
|
||||
base = {
|
||||
"company", "street", "zipcode", "city", "country",
|
||||
}
|
||||
return base | {"transmission_peppol_participant_id"}
|
||||
|
||||
def pdf_watermark(self) -> str:
|
||||
return pgettext("peppol_invoice", "Visual copy")
|
||||
|
||||
def pdf_info_text(self) -> str:
|
||||
return pgettext(
|
||||
"peppol_invoice",
|
||||
"This PDF document is a visual copy of the invoice and does not constitute an invoice for VAT "
|
||||
"purposes. The original invoice is issued in XML format and transmitted through the Peppol network."
|
||||
)
|
||||
258
src/pretix/base/invoicing/transmission.py
Normal file
258
src/pretix/base/invoicing/transmission.py
Normal file
@@ -0,0 +1,258 @@
|
||||
#
|
||||
# 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 typing import Optional
|
||||
|
||||
from django_countries.fields import Country
|
||||
|
||||
from pretix.base.models import Invoice, InvoiceAddress
|
||||
from pretix.base.signals import EventPluginRegistry, Registry
|
||||
|
||||
|
||||
class TransmissionType:
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""
|
||||
A short and unique identifier for this transmission type.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def verbose_name(self) -> str:
|
||||
"""
|
||||
A human-readable name for this transmission type to be shown internally in the backend.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def public_name(self) -> str:
|
||||
"""
|
||||
A human-readable name for this transmission type to be shown to the public.
|
||||
By default, this is the same as ``verbose_name``
|
||||
"""
|
||||
return self.verbose_name
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
"""
|
||||
Returns a priority that is used for sorting transmission type. Higher priority means higher up in the list.
|
||||
Default to 100. Providers with same priority are sorted alphabetically.
|
||||
"""
|
||||
return 100
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
"""
|
||||
If a transmission type is exclusive, no other type can be chosen if this type is
|
||||
available. Use e.g. if a certain transmission type is legally required in a certain
|
||||
jurisdiction.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def enforce_transmission(self) -> bool:
|
||||
"""
|
||||
If a transmission type enforces transmission, every invoice created with this type will be transferred.
|
||||
If not, the backend user is in some cases trusted to decide whether or not to transmit it.
|
||||
"""
|
||||
return False
|
||||
|
||||
def is_available(self, event, country: Country, is_business: bool) -> bool:
|
||||
providers = transmission_providers.filter(type=self.identifier, active_in=event)
|
||||
return any(
|
||||
provider.is_available(event, country, is_business)
|
||||
for provider, _ in providers
|
||||
)
|
||||
|
||||
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
|
||||
return set()
|
||||
|
||||
def invoice_address_form_fields_visible(self, country: Country, is_business: bool) -> set:
|
||||
return set(self.invoice_address_form_fields.keys())
|
||||
|
||||
def validate_address(self, ia: InvoiceAddress):
|
||||
pass
|
||||
|
||||
@property
|
||||
def invoice_address_form_fields(self) -> dict:
|
||||
"""
|
||||
Return a set of form fields that **must** be prefixed with ``transmission_<identifier>_``.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def form_data_to_transmission_info(self, form_data: dict) -> dict:
|
||||
return form_data
|
||||
|
||||
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
|
||||
return transmission_info
|
||||
|
||||
def pdf_watermark(self) -> Optional[str]:
|
||||
"""
|
||||
Return a watermark that should be rendered across the PDF file.
|
||||
"""
|
||||
return None
|
||||
|
||||
def pdf_info_text(self) -> Optional[str]:
|
||||
"""
|
||||
Return an info text that should be rendered on the PDF file.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class TransmissionProvider:
|
||||
"""
|
||||
Base class for a transmission provider. Should NOT hold internal state as the class is only
|
||||
instantiated once and then shared between events and organizers.
|
||||
"""
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
A short and unique identifier for this transmission provider.
|
||||
This should only contain lowercase letters and underscores.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
"""
|
||||
Identifier of the transmission type this provider provides.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
"""
|
||||
A human-readable name for this transmission provider (can be localized).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def testmode_supported(self) -> bool:
|
||||
"""
|
||||
Whether testmode invoices may be passed to this provider.
|
||||
"""
|
||||
return False
|
||||
|
||||
def is_ready(self, event) -> bool:
|
||||
"""
|
||||
Return whether this provider has all required configuration to be used in this event.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def is_available(self, event, country: Country, is_business: bool) -> bool:
|
||||
"""
|
||||
Return whether this provider may be used for an invoice for the given recipient country and address type.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def transmit(self, invoice: Invoice):
|
||||
"""
|
||||
Transmit the invoice. The invoice passed as a parameter will be in status ``TRANSMISSION_STATUS_INFLIGHT``.
|
||||
Invoices that stay in this state for more than 24h will be retried automatically. Implementations are expected to:
|
||||
|
||||
- Send the invoice.
|
||||
|
||||
- Update the ``transmission_status`` to `TRANSMISSION_STATUS_COMPLETED` or `TRANSMISSION_STATUS_FAILED`
|
||||
after sending, as well as ``transmission_info`` with provider-specific data, and ``transmission_date`` to
|
||||
the date and time of completion.
|
||||
|
||||
- Create a log entry of action type ``pretix.event.order.invoice.sent`` or
|
||||
``pretix.event.order.invoice.sending_failed`` with the fields ``full_invoice_no``, ``transmission_provider``,
|
||||
``transmission_type`` and a provider-specific ``data`` field.
|
||||
|
||||
Make sure to either handle ``invoice.order.testmode`` properly or set ``testmode_supported`` to ``False``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
"""
|
||||
Returns a priority that is used for sorting transmission providers. Higher priority will be chosen over
|
||||
lower priority for transmission. Default to 100.
|
||||
"""
|
||||
return 100
|
||||
|
||||
def settings_url(self, event) -> Optional[str]:
|
||||
"""
|
||||
Return a URL to the settings page of this provider (if any).
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class TransmissionProviderRegistry(EventPluginRegistry):
|
||||
def __init__(self):
|
||||
super().__init__({
|
||||
'identifier': lambda o: getattr(o, 'identifier'),
|
||||
'type': lambda o: getattr(o, 'type'),
|
||||
})
|
||||
|
||||
def register(self, *objs):
|
||||
for obj in objs:
|
||||
if not isinstance(obj, TransmissionProvider):
|
||||
raise TypeError('Entries must be derived from TransmissionProvider')
|
||||
|
||||
if obj.type == "email" and not obj.__module__.startswith('pretix.base.'):
|
||||
raise TypeError('No custom providers for email allowed')
|
||||
|
||||
return super().register(*objs)
|
||||
|
||||
|
||||
class TransmissionTypeRegistry(Registry):
|
||||
def __init__(self):
|
||||
super().__init__({
|
||||
'identifier': lambda o: getattr(o, 'identifier'),
|
||||
})
|
||||
|
||||
def register(self, *objs):
|
||||
for obj in objs:
|
||||
if not isinstance(obj, TransmissionType):
|
||||
raise TypeError('Entries must be derived from TransmissionType')
|
||||
|
||||
if not obj.__module__.startswith('pretix.base.'):
|
||||
raise TypeError('Plugins are currently not allowed to add transmission types')
|
||||
|
||||
return super().register(*objs)
|
||||
|
||||
|
||||
"""
|
||||
Registry for transmission providers.
|
||||
|
||||
Each entry in this registry should be an instance of a subclass of ``TransmissionProvider``.
|
||||
They are annotated with their ``identifier``, ``type``, and the defining ``plugin``.
|
||||
"""
|
||||
transmission_providers = TransmissionProviderRegistry()
|
||||
|
||||
|
||||
"""
|
||||
Registry for transmission types.
|
||||
|
||||
Each entry in this registry should be an instance of a subclass of ``TransmissionType``.
|
||||
They are annotated with their ``identifier``.
|
||||
"""
|
||||
transmission_types = TransmissionTypeRegistry()
|
||||
|
||||
|
||||
def get_transmission_types():
|
||||
return sorted(
|
||||
transmission_types.registered_entries.keys(),
|
||||
key=lambda t: (-t.priority, str(t.public_name)),
|
||||
)
|
||||
@@ -26,7 +26,7 @@ from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.signals import EventPluginRegistry
|
||||
from pretix.base.signals import PluginAwareRegistry
|
||||
|
||||
|
||||
def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
|
||||
@@ -55,7 +55,7 @@ def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
|
||||
return format_html(wrapper, **a_map)
|
||||
|
||||
|
||||
class LogEntryTypeRegistry(EventPluginRegistry):
|
||||
class LogEntryTypeRegistry(PluginAwareRegistry):
|
||||
def __init__(self):
|
||||
super().__init__({'action_type': lambda o: getattr(o, 'action_type')})
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.models import (
|
||||
Discount, Item, ItemCategory, Order, Question, Quota, SubEvent, TaxRule,
|
||||
Voucher,
|
||||
Voucher, WaitingListEntry,
|
||||
)
|
||||
|
||||
from .logentrytype_registry import ( # noqa
|
||||
@@ -145,3 +145,15 @@ class TaxRuleLogEntryType(EventLogEntryType):
|
||||
object_link_viewname = 'control:event.settings.tax.edit'
|
||||
object_link_argname = 'rule'
|
||||
content_type = TaxRule
|
||||
|
||||
|
||||
class WaitingListEntryLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = '{val}'
|
||||
object_link_viewname = 'control:event.orders.waitinglist'
|
||||
content_type = WaitingListEntry
|
||||
|
||||
def get_object_link_info(self, logentry) -> Optional[dict]:
|
||||
info = super().get_object_link_info(logentry)
|
||||
if info and 'href' in info:
|
||||
info['href'] += '?status=a&entry=' + str(logentry.content_object.pk)
|
||||
return info
|
||||
|
||||
35
src/pretix/base/management/commands/makemessages.py
Normal file
35
src/pretix/base/management/commands/makemessages.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#
|
||||
# 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 re
|
||||
|
||||
from django.core.management.commands import makemessages
|
||||
|
||||
|
||||
def is_valid_locale(locale):
|
||||
return re.match(r"^[a-z]+$", locale) or re.match(r"^[a-z]+_[A-Z0-9].*$", locale)
|
||||
|
||||
|
||||
makemessages.is_valid_locale = is_valid_locale
|
||||
|
||||
|
||||
class Command(makemessages.Command):
|
||||
pass
|
||||
@@ -38,6 +38,7 @@ import traceback
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import close_old_connections
|
||||
from django.dispatch.dispatcher import NO_RECEIVERS
|
||||
|
||||
from pretix.helpers.periodic import SKIPPED
|
||||
@@ -79,6 +80,8 @@ class Command(BaseCommand):
|
||||
self.stdout.write(f'INFO Running {name}…')
|
||||
t0 = time.time()
|
||||
try:
|
||||
# Check if the DB connection is still good, it might be closed if the previous task took too long.
|
||||
close_old_connections()
|
||||
r = receiver(signal=periodic_task, sender=self)
|
||||
except Exception as err:
|
||||
if isinstance(err, KeyboardInterrupt):
|
||||
|
||||
18
src/pretix/base/migrations/0280_cartposition_max_extend.py
Normal file
18
src/pretix/base/migrations/0280_cartposition_max_extend.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.20 on 2025-05-14 14:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0279_discount_event_date_from_discount_event_date_until'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='max_extend',
|
||||
field=models.DateTimeField(null=True),
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0281_event_is_remote.py
Normal file
18
src/pretix/base/migrations/0281_event_is_remote.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.16 on 2025-05-20 11:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0280_cartposition_max_extend"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="event",
|
||||
name="is_remote",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
24
src/pretix/base/migrations/0282_taxrule_default.py
Normal file
24
src/pretix/base/migrations/0282_taxrule_default.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0281_event_is_remote"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="taxrule",
|
||||
name="default",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="taxrule",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("default", True)),
|
||||
fields=("event",),
|
||||
name="one_default_per_event",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 4.2.17 on 2025-03-28 09:19
|
||||
from django.core.cache import cache
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Count, Exists, OuterRef
|
||||
|
||||
|
||||
def set_default_tax_rate(app, schema_editor):
|
||||
Event = app.get_model('pretixbase', 'Event')
|
||||
Event_SettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
|
||||
TaxRule = app.get_model('pretixbase', 'TaxRule')
|
||||
|
||||
# Handling of events with tax_rate_default set
|
||||
for s in Event_SettingsStore.objects.filter(key="tax_rate_default").iterator():
|
||||
updated = TaxRule.objects.filter(pk=s.value, event_id=s.object_id).update(default=True)
|
||||
if updated:
|
||||
# Delete deprecated settings key
|
||||
s.delete()
|
||||
|
||||
# The default for new events is tax_rule_cancellation=none, but since we do not change behaviour
|
||||
# for existing events without warning, we create a settings entry that matches the old behaviour.
|
||||
Event_SettingsStore.objects.get_or_create(
|
||||
object_id=s.object_id,
|
||||
key="tax_rule_cancellation",
|
||||
defaults={"value": "default"},
|
||||
)
|
||||
|
||||
# We do not need to set tax_rule_payment here since "default" is the default
|
||||
|
||||
cache.delete('hierarkey_{}_{}'.format('event', s.object_id))
|
||||
|
||||
# Handling of events with tax_rate_default not set
|
||||
for e in Event.objects.only("pk").exclude(Exists(TaxRule.objects.filter(default=True, event_id=OuterRef("pk")))).iterator():
|
||||
fav_tax_rules = e.tax_rules.annotate(c=Count("item")).order_by("-c", "pk")[:1]
|
||||
if fav_tax_rules:
|
||||
fav_tax_rules[0].default = True
|
||||
fav_tax_rules[0].save()
|
||||
|
||||
# Previously, no tax rule was set for payments, so keep it this way
|
||||
Event_SettingsStore.objects.get_or_create(
|
||||
object=e,
|
||||
key="tax_rule_payment",
|
||||
defaults={"value": "none"},
|
||||
)
|
||||
cache.delete('hierarkey_{}_{}'.format('event', e.pk))
|
||||
|
||||
# We do not need to set tax_rule_cancellation, as "none" is the new system default
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0282_taxrule_default"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
set_default_tax_rate,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 4.2.21 on 2025-06-27 13:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0283_taxrule_default_taxrule_backfill'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OrderSyncResult',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('sync_provider', models.CharField(max_length=128)),
|
||||
('mapping_id', models.IntegerField()),
|
||||
('external_object_type', models.CharField(max_length=128)),
|
||||
('external_id_field', models.CharField(max_length=128)),
|
||||
('id_value', models.CharField(max_length=128)),
|
||||
('external_link_href', models.CharField(max_length=255, null=True)),
|
||||
('external_link_display_name', models.CharField(max_length=255, null=True)),
|
||||
('transmitted', models.DateTimeField(auto_now_add=True)),
|
||||
('sync_info', models.JSONField()),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sync_results', to='pretixbase.order')),
|
||||
('order_position', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sync_results', to='pretixbase.orderposition')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['order', 'sync_provider'], name='pretixbase__order_i_3e3c84_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrderSyncQueue',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('sync_provider', models.CharField(max_length=128)),
|
||||
('triggered_by', models.CharField(max_length=128)),
|
||||
('triggered', models.DateTimeField(auto_now_add=True)),
|
||||
('failed_attempts', models.PositiveIntegerField(default=0)),
|
||||
('not_before', models.DateTimeField(db_index=True)),
|
||||
('need_manual_retry', models.CharField(null=True, max_length=20)),
|
||||
('in_flight', models.BooleanField(default=False)),
|
||||
('in_flight_since', models.DateTimeField(blank=True, null=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='queued_sync_jobs', to='pretixbase.event')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='queued_sync_jobs', to='pretixbase.order')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('triggered',),
|
||||
'unique_together': {('order', 'sync_provider', 'in_flight')},
|
||||
},
|
||||
),
|
||||
]
|
||||
46
src/pretix/base/migrations/0285_voucher_created.py
Normal file
46
src/pretix/base/migrations/0285_voucher_created.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 4.2.16 on 2025-08-08 09:13
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Min
|
||||
from django.utils.timezone import now
|
||||
|
||||
|
||||
def backfill_voucher_created(apps, schema_editor):
|
||||
Voucher = apps.get_model("pretixbase", "Voucher")
|
||||
LogEntry = apps.get_model("pretixbase", "LogEntry")
|
||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||
ct = None
|
||||
|
||||
for v in Voucher.objects.filter(created__isnull=True).iterator():
|
||||
if not ct:
|
||||
# "Lazy-loading" to prevent this to be executed on new DBs where the content type does not yet
|
||||
# exist -- but also no vouchers do
|
||||
ct = ContentType.objects.get(app_label='pretixbase', model='voucher')
|
||||
v.created = LogEntry.objects.filter(
|
||||
content_type=ct,
|
||||
object_id=v.pk,
|
||||
).aggregate(m=Min("datetime"))["m"] or now()
|
||||
v.save(update_fields=["created"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0284_ordersyncresult_ordersyncqueue"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="voucher",
|
||||
name="created",
|
||||
field=models.DateTimeField(auto_now_add=True, null=True),
|
||||
),
|
||||
migrations.RunPython(
|
||||
backfill_voucher_created,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="voucher",
|
||||
name="created",
|
||||
field=models.DateTimeField(auto_now_add=True),
|
||||
),
|
||||
]
|
||||
28
src/pretix/base/migrations/0286_settingsstore_unique.py
Normal file
28
src/pretix/base/migrations/0286_settingsstore_unique.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 4.2.16 on 2025-08-14 09:40
|
||||
|
||||
from django.db import migrations
|
||||
from hierarkey.utils import CleanHierarkeyDuplicates
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0285_voucher_created"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
CleanHierarkeyDuplicates("GlobalSettingsObject_SettingsStore"),
|
||||
CleanHierarkeyDuplicates("Organizer_SettingsStore"),
|
||||
CleanHierarkeyDuplicates("Event_SettingsStore"),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="event_settingsstore",
|
||||
unique_together={("object", "key")},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="globalsettingsobject_settingsstore",
|
||||
unique_together={("key",)},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="organizer_settingsstore",
|
||||
unique_together={("object", "key")},
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0287_organizer_plugins.py
Normal file
18
src/pretix/base/migrations/0287_organizer_plugins.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.17 on 2025-07-12 09:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0286_settingsstore_unique"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="organizer",
|
||||
name="plugins",
|
||||
field=models.TextField(default=""),
|
||||
),
|
||||
]
|
||||
75
src/pretix/base/migrations/0288_invoice_transmission.py
Normal file
75
src/pretix/base/migrations/0288_invoice_transmission.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# Generated by Django 4.2.17 on 2025-04-21 11:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0287_organizer_plugins"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="invoice",
|
||||
old_name="sent_to_customer",
|
||||
new_name="transmission_date",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="invoice",
|
||||
name="invoice_to_transmission_info",
|
||||
field=models.JSONField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="invoice",
|
||||
name="transmission_info",
|
||||
field=models.JSONField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="invoice",
|
||||
name="transmission_provider",
|
||||
field=models.CharField(max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="invoice",
|
||||
name="transmission_status",
|
||||
field=models.CharField(default="unknown", max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="invoice",
|
||||
name="created",
|
||||
field=models.DateTimeField(auto_now_add=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="invoice",
|
||||
name="invoice_to_is_business",
|
||||
field=models.BooleanField(null=True),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"UPDATE pretixbase_invoice SET transmission_status = 'completed' WHERE transmission_date IS NOT NULL",
|
||||
migrations.RunSQL.noop,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="invoice",
|
||||
name="transmission_type",
|
||||
field=models.CharField(default="email", max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="invoiceaddress",
|
||||
name="transmission_info",
|
||||
field=models.JSONField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="invoiceaddress",
|
||||
name="transmission_type",
|
||||
field=models.CharField(default="email", max_length=255),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"UPDATE pretixbase_event_settingsstore SET key = 'mail_text_order_invoice' WHERE key = 'payment_banktransfer_invoice_email_text'",
|
||||
"UPDATE pretixbase_event_settingsstore SET key = 'payment_banktransfer_invoice_email_text' WHERE key = 'mail_text_order_invoice'",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"UPDATE pretixbase_event_settingsstore SET key = 'mail_subject_order_invoice' WHERE key = 'payment_banktransfer_invoice_email_subject'",
|
||||
"UPDATE pretixbase_event_settingsstore SET key = 'payment_banktransfer_invoice_email_subject' WHERE key = 'mail_subject_order_invoice'",
|
||||
),
|
||||
]
|
||||
78
src/pretix/base/migrations/0289_invoiceline_period.py
Normal file
78
src/pretix/base/migrations/0289_invoiceline_period.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# Generated by Django 4.2.17 on 2025-09-08 08:14
|
||||
from django.core.cache import cache
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_invoice_period(apps, schema_editor):
|
||||
EventSettingsStore = apps.get_model("pretixbase", "Event_SettingsStore")
|
||||
ev_seen = set()
|
||||
insert_queue = []
|
||||
flush_queue = []
|
||||
|
||||
def store():
|
||||
EventSettingsStore.objects.bulk_create(
|
||||
insert_queue,
|
||||
update_conflicts=True,
|
||||
update_fields=["value"],
|
||||
unique_fields=["object", "key"],
|
||||
)
|
||||
cache.delete_many(flush_queue)
|
||||
flush_queue.clear()
|
||||
insert_queue.clear()
|
||||
|
||||
# Existing events that use pretix-zugferd and have explicitly disabled delivery dates
|
||||
for setting in EventSettingsStore.objects.filter(key="zugferd_include_delivery_date", value="False"):
|
||||
flush_queue.append("hierarkey_{}_{}".format("event", setting.object_id))
|
||||
insert_queue.append(
|
||||
EventSettingsStore(
|
||||
object_id=setting.object_id,
|
||||
key="invoice_period",
|
||||
value="invoice_date",
|
||||
)
|
||||
)
|
||||
ev_seen.add(setting.object_id)
|
||||
|
||||
if len(insert_queue) > 1000:
|
||||
store()
|
||||
|
||||
# Existing events that previously hid their date on invoices
|
||||
for setting in EventSettingsStore.objects.filter(key="show_dates_on_frontpage", value="False"):
|
||||
if setting.object_id in ev_seen:
|
||||
continue
|
||||
|
||||
flush_queue.append("hierarkey_{}_{}".format("event", setting.object_id))
|
||||
insert_queue.append(
|
||||
EventSettingsStore(
|
||||
object_id=setting.object_id,
|
||||
key="invoice_period",
|
||||
value="auto_no_event",
|
||||
)
|
||||
)
|
||||
|
||||
if len(insert_queue) > 1000:
|
||||
store()
|
||||
|
||||
store()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0288_invoice_transmission"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="invoiceline",
|
||||
old_name="event_date_to",
|
||||
new_name="period_end",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="invoiceline",
|
||||
old_name="event_date_from",
|
||||
new_name="period_start",
|
||||
),
|
||||
migrations.RunPython(
|
||||
set_invoice_period,
|
||||
migrations.RunPython.noop,
|
||||
)
|
||||
]
|
||||
18
src/pretix/base/migrations/0290_invoice_plugin_data.py
Normal file
18
src/pretix/base/migrations/0290_invoice_plugin_data.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.17 on 2025-09-09 09:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0289_invoiceline_period"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="invoice",
|
||||
name="plugin_data",
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
]
|
||||
@@ -111,6 +111,13 @@ class ImportColumn:
|
||||
"""
|
||||
return gettext_lazy('Keep empty')
|
||||
|
||||
@property
|
||||
def help_text(self):
|
||||
"""
|
||||
Additional description of the column
|
||||
"""
|
||||
return None
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ from pretix.base.signals import order_import_columns
|
||||
class EmailColumn(ImportColumn):
|
||||
identifier = 'email'
|
||||
verbose_name = gettext_lazy('Email address')
|
||||
order_level = True
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
@@ -67,9 +68,24 @@ class EmailColumn(ImportColumn):
|
||||
order.email = value
|
||||
|
||||
|
||||
class GroupingColumn(ImportColumn):
|
||||
identifier = 'grouping'
|
||||
verbose_name = gettext_lazy('Grouping')
|
||||
help_text = gettext_lazy(
|
||||
'Only applicable when "Import mode" is set to "Group multiple lines together...". Lines with the same grouping '
|
||||
'value will be put in the same order, but MUST be consecutive lines of the input file.'
|
||||
)
|
||||
order_level = True
|
||||
default_label = "---"
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class PhoneColumn(ImportColumn):
|
||||
identifier = 'phone'
|
||||
verbose_name = gettext_lazy('Phone number')
|
||||
order_level = True
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
@@ -94,6 +110,10 @@ class SubeventColumn(SubeventColumnMixin, ImportColumn):
|
||||
identifier = 'subevent'
|
||||
verbose_name = pgettext_lazy('subevents', 'Date')
|
||||
default_value = None
|
||||
help_text = pgettext_lazy(
|
||||
'subevents', 'The date can be specified through its full name, full date and time, or internal ID, provided '
|
||||
'only one date in the system matches the input.'
|
||||
)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
@@ -108,6 +128,7 @@ class ItemColumn(ImportColumn):
|
||||
identifier = 'item'
|
||||
verbose_name = gettext_lazy('Product')
|
||||
default_value = None
|
||||
help_text = gettext_lazy('The product can be specified by its internal ID, full name or internal name.')
|
||||
|
||||
@cached_property
|
||||
def items(self):
|
||||
@@ -137,6 +158,7 @@ class ItemColumn(ImportColumn):
|
||||
class Variation(ImportColumn):
|
||||
identifier = 'variation'
|
||||
verbose_name = gettext_lazy('Product variation')
|
||||
help_text = gettext_lazy('The variation can be specified by its internal ID or full name.')
|
||||
|
||||
@cached_property
|
||||
def items(self):
|
||||
@@ -170,6 +192,7 @@ class Variation(ImportColumn):
|
||||
|
||||
class InvoiceAddressCompany(ImportColumn):
|
||||
identifier = 'invoice_address_company'
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -181,6 +204,8 @@ class InvoiceAddressCompany(ImportColumn):
|
||||
|
||||
|
||||
class InvoiceAddressNamePart(ImportColumn):
|
||||
order_level = True
|
||||
|
||||
def __init__(self, event, key, label):
|
||||
self.key = key
|
||||
self.label = label
|
||||
@@ -200,6 +225,7 @@ class InvoiceAddressNamePart(ImportColumn):
|
||||
|
||||
class InvoiceAddressStreet(ImportColumn):
|
||||
identifier = 'invoice_address_street'
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -211,6 +237,7 @@ class InvoiceAddressStreet(ImportColumn):
|
||||
|
||||
class InvoiceAddressZip(ImportColumn):
|
||||
identifier = 'invoice_address_zipcode'
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -222,6 +249,7 @@ class InvoiceAddressZip(ImportColumn):
|
||||
|
||||
class InvoiceAddressCity(ImportColumn):
|
||||
identifier = 'invoice_address_city'
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -234,6 +262,8 @@ class InvoiceAddressCity(ImportColumn):
|
||||
class InvoiceAddressCountry(ImportColumn):
|
||||
identifier = 'invoice_address_country'
|
||||
default_value = None
|
||||
help_text = gettext_lazy('The country needs to be specified using a two-letter country code.')
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
@@ -257,6 +287,8 @@ class InvoiceAddressCountry(ImportColumn):
|
||||
|
||||
class InvoiceAddressState(ImportColumn):
|
||||
identifier = 'invoice_address_state'
|
||||
help_text = gettext_lazy('The state can be specified by its short form or full name.')
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -282,6 +314,7 @@ class InvoiceAddressState(ImportColumn):
|
||||
|
||||
class InvoiceAddressVATID(ImportColumn):
|
||||
identifier = 'invoice_address_vat_id'
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -293,6 +326,7 @@ class InvoiceAddressVATID(ImportColumn):
|
||||
|
||||
class InvoiceAddressReference(ImportColumn):
|
||||
identifier = 'invoice_address_internal_reference'
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -380,6 +414,7 @@ class AttendeeCity(ImportColumn):
|
||||
class AttendeeCountry(ImportColumn):
|
||||
identifier = 'attendee_country'
|
||||
default_value = None
|
||||
help_text = gettext_lazy('The country needs to be specified using a two-letter country code.')
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
@@ -403,6 +438,7 @@ class AttendeeCountry(ImportColumn):
|
||||
|
||||
class AttendeeState(ImportColumn):
|
||||
identifier = 'attendee_state'
|
||||
help_text = gettext_lazy('The state can be specified by its short form or full name.')
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
@@ -471,6 +507,7 @@ class Locale(ImportColumn):
|
||||
identifier = 'locale'
|
||||
verbose_name = gettext_lazy('Order locale')
|
||||
default_value = None
|
||||
order_level = True
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
@@ -514,6 +551,7 @@ class ValidUntil(DatetimeColumnMixin, ImportColumn):
|
||||
class Expires(DatetimeColumnMixin, ImportColumn):
|
||||
identifier = 'expires'
|
||||
verbose_name = gettext_lazy('Expiry date')
|
||||
order_level = True
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
@@ -540,6 +578,8 @@ class Saleschannel(ImportColumn):
|
||||
verbose_name = gettext_lazy('Sales channel')
|
||||
default_value = None
|
||||
initial = 'static:web'
|
||||
help_text = gettext_lazy('The sales channel can be specified by it\'s internal identifier or its full name.')
|
||||
order_level = True
|
||||
|
||||
@cached_property
|
||||
def channels(self):
|
||||
@@ -568,6 +608,7 @@ class Saleschannel(ImportColumn):
|
||||
class SeatColumn(ImportColumn):
|
||||
identifier = 'seat'
|
||||
verbose_name = gettext_lazy('Seat ID')
|
||||
help_text = gettext_lazy('The seat needs to be specified by its internal ID.')
|
||||
|
||||
def __init__(self, *args):
|
||||
self._cached = set()
|
||||
@@ -599,7 +640,8 @@ class SeatColumn(ImportColumn):
|
||||
|
||||
class Comment(ImportColumn):
|
||||
identifier = 'comment'
|
||||
verbose_name = gettext_lazy('Comment')
|
||||
verbose_name = gettext_lazy('Order comment')
|
||||
order_level = True
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
order.comment = value or ''
|
||||
@@ -608,6 +650,7 @@ class Comment(ImportColumn):
|
||||
class CheckinAttentionColumn(BooleanColumnMixin, ImportColumn):
|
||||
identifier = 'checkin_attention'
|
||||
verbose_name = gettext_lazy('Requires special attention')
|
||||
order_level = True
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
order.checkin_attention = value
|
||||
@@ -616,6 +659,7 @@ class CheckinAttentionColumn(BooleanColumnMixin, ImportColumn):
|
||||
class CheckinTextColumn(ImportColumn):
|
||||
identifier = 'checkin_text'
|
||||
verbose_name = gettext_lazy('Check-in text')
|
||||
order_level = True
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
order.checkin_text = value
|
||||
@@ -696,6 +740,7 @@ class QuestionColumn(ImportColumn):
|
||||
class CustomerColumn(ImportColumn):
|
||||
identifier = 'customer'
|
||||
verbose_name = gettext_lazy('Customer')
|
||||
order_level = True
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
@@ -720,6 +765,7 @@ def get_order_import_columns(event):
|
||||
if event.has_subevents:
|
||||
default.append(SubeventColumn(event))
|
||||
default += [
|
||||
GroupingColumn(event),
|
||||
EmailColumn(event),
|
||||
PhoneColumn(event),
|
||||
ItemColumn(event),
|
||||
|
||||
@@ -350,6 +350,7 @@ class Checkin(models.Model):
|
||||
REASON_BLOCKED = 'blocked'
|
||||
REASON_UNAPPROVED = 'unapproved'
|
||||
REASON_INVALID_TIME = 'invalid_time'
|
||||
REASON_ANNULLED = 'annulled'
|
||||
REASONS = (
|
||||
(REASON_CANCELED, _('Order canceled')),
|
||||
(REASON_INVALID, _('Unknown ticket')),
|
||||
@@ -364,6 +365,7 @@ class Checkin(models.Model):
|
||||
(REASON_BLOCKED, _('Ticket blocked')),
|
||||
(REASON_UNAPPROVED, _('Order not approved')),
|
||||
(REASON_INVALID_TIME, _('Ticket not valid at this time')),
|
||||
(REASON_ANNULLED, _('Check-in annulled')),
|
||||
)
|
||||
|
||||
successful = models.BooleanField(
|
||||
|
||||
149
src/pretix/base/models/datasync.py
Normal file
149
src/pretix/base/models/datasync.py
Normal file
@@ -0,0 +1,149 @@
|
||||
#
|
||||
# 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
|
||||
from functools import cached_property
|
||||
|
||||
from django.db import IntegrityError, models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from pretix.base.models import Event, Order, OrderPosition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MODE_OVERWRITE = "overwrite"
|
||||
MODE_SET_IF_NEW = "if_new"
|
||||
MODE_SET_IF_EMPTY = "if_empty"
|
||||
MODE_APPEND_LIST = "append"
|
||||
|
||||
|
||||
class OrderSyncQueue(models.Model):
|
||||
order = models.ForeignKey(
|
||||
Order, on_delete=models.CASCADE, related_name="queued_sync_jobs"
|
||||
)
|
||||
event = models.ForeignKey(
|
||||
Event, on_delete=models.CASCADE, related_name="queued_sync_jobs"
|
||||
)
|
||||
sync_provider = models.CharField(blank=False, null=False, max_length=128)
|
||||
triggered_by = models.CharField(blank=False, null=False, max_length=128)
|
||||
triggered = models.DateTimeField(blank=False, null=False, auto_now_add=True)
|
||||
failed_attempts = models.PositiveIntegerField(default=0)
|
||||
not_before = models.DateTimeField(blank=False, null=False, db_index=True)
|
||||
need_manual_retry = models.CharField(blank=True, null=True, max_length=20, choices=[
|
||||
('exceeded', _('Temporary error, auto-retry limit exceeded')),
|
||||
('permanent', _('Provider reported a permanent error')),
|
||||
('config', _('Misconfiguration, please check provider settings')),
|
||||
('internal', _('System error, needs manual intervention')),
|
||||
('timeout', _('System error, needs manual intervention')),
|
||||
])
|
||||
in_flight = models.BooleanField(default=False)
|
||||
in_flight_since = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = (("order", "sync_provider", "in_flight"),)
|
||||
ordering = ("triggered",)
|
||||
|
||||
@cached_property
|
||||
def _provider_class_info(self):
|
||||
from pretix.base.datasync.datasync import datasync_providers
|
||||
return datasync_providers.get(identifier=self.sync_provider)
|
||||
|
||||
@property
|
||||
def provider_class(self):
|
||||
return self._provider_class_info[0]
|
||||
|
||||
@property
|
||||
def provider_display_name(self):
|
||||
return self.provider_class.display_name
|
||||
|
||||
@property
|
||||
def is_provider_active(self):
|
||||
return self._provider_class_info[1]
|
||||
|
||||
@property
|
||||
def max_retry_attempts(self):
|
||||
return self.provider_class.max_attempts
|
||||
|
||||
def set_sync_error(self, failure_mode, messages, full_message):
|
||||
logger.exception(
|
||||
f"Could not sync order {self.order.code} to {type(self).__name__} ({failure_mode})"
|
||||
)
|
||||
self.order.log_action(f"pretix.event.order.data_sync.failed.{failure_mode}", {
|
||||
"provider": self.sync_provider,
|
||||
"error": messages,
|
||||
"full_message": full_message,
|
||||
})
|
||||
self.need_manual_retry = failure_mode
|
||||
self.clear_in_flight()
|
||||
|
||||
def clear_in_flight(self):
|
||||
self.in_flight = False
|
||||
self.in_flight_since = None
|
||||
try:
|
||||
self.save()
|
||||
except IntegrityError:
|
||||
# if setting in_flight=False fails due to UNIQUE constraint, just delete the current instance
|
||||
self.delete()
|
||||
|
||||
|
||||
class OrderSyncResult(models.Model):
|
||||
order = models.ForeignKey(
|
||||
Order, on_delete=models.CASCADE, related_name="sync_results"
|
||||
)
|
||||
sync_provider = models.CharField(blank=False, null=False, max_length=128)
|
||||
order_position = models.ForeignKey(
|
||||
OrderPosition, on_delete=models.CASCADE, related_name="sync_results", blank=True, null=True,
|
||||
)
|
||||
mapping_id = models.IntegerField(blank=False, null=False)
|
||||
external_object_type = models.CharField(blank=False, null=False, max_length=128)
|
||||
external_id_field = models.CharField(blank=False, null=False, max_length=128)
|
||||
id_value = models.CharField(blank=False, null=False, max_length=128)
|
||||
external_link_href = models.CharField(blank=True, null=True, max_length=255)
|
||||
external_link_display_name = models.CharField(blank=True, null=True, max_length=255)
|
||||
transmitted = models.DateTimeField(blank=False, null=False, auto_now_add=True)
|
||||
sync_info = models.JSONField()
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=("order", "sync_provider")),
|
||||
]
|
||||
|
||||
def external_link_html(self):
|
||||
if not self.external_link_display_name:
|
||||
return None
|
||||
|
||||
from pretix.base.datasync.datasync import datasync_providers
|
||||
prov, meta = datasync_providers.get(identifier=self.sync_provider)
|
||||
if prov:
|
||||
return prov.get_external_link_html(self.order.event, self.external_link_href, self.external_link_display_name)
|
||||
|
||||
def to_result_dict(self):
|
||||
return {
|
||||
"position": self.order_position_id,
|
||||
"object_type": self.external_object_type,
|
||||
"external_id_field": self.external_id_field,
|
||||
"id_value": self.id_value,
|
||||
"external_link_href": self.external_link_href,
|
||||
"external_link_display_name": self.external_link_display_name,
|
||||
**self.sync_info,
|
||||
}
|
||||
@@ -243,8 +243,16 @@ class EventMixin:
|
||||
def waiting_list_active(self):
|
||||
if not self.settings.waiting_list_enabled:
|
||||
return False
|
||||
|
||||
if self.settings.waiting_list_auto_disable:
|
||||
return self.settings.waiting_list_auto_disable.datetime(self) > time_machine_now()
|
||||
if self.settings.waiting_list_auto_disable.datetime(self) <= time_machine_now():
|
||||
return False
|
||||
|
||||
if hasattr(self, 'active_quotas'):
|
||||
# Only run when called with computed quotas, i.e. event calendar
|
||||
if not self.best_availability[3]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
@@ -322,9 +330,7 @@ class EventMixin:
|
||||
sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher).filter(
|
||||
Q(variations__isnull=True)
|
||||
& Q(quotas__pk=OuterRef('pk'))
|
||||
).order_by().values_list('quotas__pk').annotate(
|
||||
items=GroupConcat('pk', delimiter=',')
|
||||
).values('items')
|
||||
)
|
||||
|
||||
q_variation = (
|
||||
Q(active=True)
|
||||
@@ -357,9 +363,7 @@ class EventMixin:
|
||||
q_variation &= Q(hide_without_voucher=False)
|
||||
q_variation &= Q(item__hide_without_voucher=False)
|
||||
|
||||
sq_active_variation = ItemVariation.objects.filter(q_variation).order_by().values_list('quotas__pk').annotate(
|
||||
items=GroupConcat('pk', delimiter=',')
|
||||
).values('items')
|
||||
sq_active_variation = ItemVariation.objects.filter(q_variation)
|
||||
quota_base_qs = Quota.objects.using(settings.DATABASE_REPLICA).filter(
|
||||
ignore_for_event_availability=False
|
||||
)
|
||||
@@ -376,8 +380,23 @@ class EventMixin:
|
||||
'quotas',
|
||||
to_attr='active_quotas',
|
||||
queryset=quota_base_qs.annotate(
|
||||
active_items=Subquery(sq_active_item, output_field=models.TextField()),
|
||||
active_variations=Subquery(sq_active_variation, output_field=models.TextField()),
|
||||
active_items=Subquery(
|
||||
sq_active_item.order_by().values_list('quotas__pk').annotate(
|
||||
items=GroupConcat('pk', delimiter=',')
|
||||
).values('items'),
|
||||
output_field=models.TextField()
|
||||
),
|
||||
active_variations=Subquery(
|
||||
sq_active_variation.order_by().values_list('quotas__pk').annotate(
|
||||
items=GroupConcat('pk', delimiter=',')
|
||||
).values('items'),
|
||||
output_field=models.TextField()),
|
||||
has_active_items_with_waitinglist=Exists(
|
||||
sq_active_item.filter(allow_waitinglist=True),
|
||||
),
|
||||
has_active_variations_with_waitinglist=Exists(
|
||||
sq_active_variation.filter(item__allow_waitinglist=True),
|
||||
),
|
||||
).exclude(
|
||||
Q(active_items="") & Q(active_variations="")
|
||||
).select_related('event', 'subevent')
|
||||
@@ -406,11 +425,12 @@ class EventMixin:
|
||||
@cached_property
|
||||
def best_availability(self):
|
||||
"""
|
||||
Returns a 3-tuple of
|
||||
Returns a 4-tuple of
|
||||
|
||||
- The availability state of this event (one of the ``Quota.AVAILABILITY_*`` constants)
|
||||
- The number of tickets currently available (or ``None``)
|
||||
- The number of tickets "originally" available (or ``None``)
|
||||
- Whether a sold out product has the waiting list enabled
|
||||
|
||||
This can only be called on objects obtained through a queryset that has been passed through ``.annotated()``.
|
||||
"""
|
||||
@@ -433,6 +453,7 @@ class EventMixin:
|
||||
r = getattr(self, '_quota_cache', {})
|
||||
quotas_for_item = defaultdict(list)
|
||||
quotas_for_variation = defaultdict(list)
|
||||
waiting_list_found = False
|
||||
for q in self.active_quotas:
|
||||
if q not in r:
|
||||
r[q] = q.availability(allow_cache=True)
|
||||
@@ -441,6 +462,8 @@ class EventMixin:
|
||||
for item_id in q.active_items.split(","):
|
||||
if item_id not in items_disabled:
|
||||
quotas_for_item[item_id].append(q)
|
||||
if q.has_active_items_with_waitinglist or q.has_active_variations_with_waitinglist:
|
||||
waiting_list_found = True
|
||||
if q.active_variations:
|
||||
for var_id in q.active_variations.split(","):
|
||||
if var_id not in vars_disabled:
|
||||
@@ -448,7 +471,7 @@ class EventMixin:
|
||||
|
||||
if not self.active_quotas or (not quotas_for_item and not quotas_for_variation):
|
||||
# No item is enabled for this event, treat the event as "unknown"
|
||||
return None, None, None
|
||||
return None, None, None, waiting_list_found
|
||||
|
||||
# We iterate over all items and variations and keep track of
|
||||
# - `best_state_found` - the best availability state we have seen so far. If one item is available, the event is available!
|
||||
@@ -467,7 +490,7 @@ class EventMixin:
|
||||
quotas_that_are_not_unlimited = [q for q in quota_list if q.size is not None]
|
||||
if not quotas_that_are_not_unlimited:
|
||||
# We found an unlimited ticket, no more need to do anything else
|
||||
return Quota.AVAILABILITY_OK, None, None
|
||||
return Quota.AVAILABILITY_OK, None, None, waiting_list_found
|
||||
|
||||
if worst_state_for_ticket == Quota.AVAILABILITY_OK:
|
||||
availability_of_this = min(max(0, r[q][1] - quota_used_for_found_tickets[q]) for q in quotas_that_are_not_unlimited)
|
||||
@@ -481,7 +504,8 @@ class EventMixin:
|
||||
quota_used_for_possible_tickets[q] += possible_of_this
|
||||
|
||||
best_state_found = max(best_state_found, worst_state_for_ticket)
|
||||
return best_state_found, num_tickets_found, num_tickets_possible
|
||||
|
||||
return best_state_found, num_tickets_found, num_tickets_possible, waiting_list_found
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
|
||||
assert isinstance(sales_channel, str) or sales_channel is None
|
||||
@@ -551,8 +575,7 @@ class Event(EventMixin, LoggedModel):
|
||||
:type presale_end: datetime
|
||||
:param location: venue
|
||||
:type location: str
|
||||
:param plugins: A comma-separated list of plugin names that are active for this
|
||||
event.
|
||||
:param plugins: A comma-separated list of plugin names that are active for this event.
|
||||
:type plugins: str
|
||||
:param has_subevents: Enable event series functionality
|
||||
:type has_subevents: bool
|
||||
@@ -616,6 +639,11 @@ class Event(EventMixin, LoggedModel):
|
||||
max_length=200,
|
||||
verbose_name=_("Location"),
|
||||
)
|
||||
is_remote = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("This event is remote or partially remote."),
|
||||
help_text=_("This will be used to let users know if the event is in a different timezone and let’s us calculate users’ local times."),
|
||||
)
|
||||
geo_lat = models.FloatField(
|
||||
verbose_name=_("Latitude"),
|
||||
null=True, blank=True,
|
||||
@@ -1079,7 +1107,8 @@ class Event(EventMixin, LoggedModel):
|
||||
s.product = item_map[s.product_id]
|
||||
s.save(force_insert=True)
|
||||
|
||||
skip_settings = (
|
||||
valid_sales_channel_identifers = set(self.organizer.sales_channels.values_list("identifier", flat=True))
|
||||
skip_settings = {
|
||||
'ticket_secrets_pretix_sig1_pubkey',
|
||||
'ticket_secrets_pretix_sig1_privkey',
|
||||
# no longer used, but we still don't need to copy them
|
||||
@@ -1087,7 +1116,10 @@ class Event(EventMixin, LoggedModel):
|
||||
'presale_css_checksum',
|
||||
'presale_widget_css_file',
|
||||
'presale_widget_css_checksum',
|
||||
)
|
||||
} | {
|
||||
# Some settings might already exist due to e.g. the timezone being special in the API
|
||||
s.key for s in self.settings._objects.all()
|
||||
}
|
||||
settings_to_save = []
|
||||
for s in other.settings._objects.all():
|
||||
if s.key in skip_settings:
|
||||
@@ -1107,13 +1139,11 @@ class Event(EventMixin, LoggedModel):
|
||||
newname = default_storage.save(fname, fi)
|
||||
s.value = 'file://' + newname
|
||||
settings_to_save.append(s)
|
||||
elif s.key == 'tax_rate_default':
|
||||
try:
|
||||
if int(s.value) in tax_map:
|
||||
s.value = tax_map.get(int(s.value)).pk
|
||||
settings_to_save.append(s)
|
||||
except ValueError:
|
||||
pass
|
||||
elif s.key.startswith('payment_') and s.key.endswith('__restrict_to_sales_channels'):
|
||||
data = other.settings._unserialize(s.value, as_type=list)
|
||||
data = [ident for ident in data if ident in valid_sales_channel_identifers]
|
||||
s.value = other.settings._serialize(data)
|
||||
settings_to_save.append(s)
|
||||
else:
|
||||
settings_to_save.append(s)
|
||||
other.settings._objects.bulk_create(settings_to_save)
|
||||
@@ -1187,6 +1217,10 @@ class Event(EventMixin, LoggedModel):
|
||||
renderers[pp.identifier] = pp
|
||||
return renderers
|
||||
|
||||
@cached_property
|
||||
def cached_default_tax_rule(self):
|
||||
return self.tax_rules.filter(default=True).first()
|
||||
|
||||
@cached_property
|
||||
def ticket_secret_generators(self) -> dict:
|
||||
"""
|
||||
@@ -1382,7 +1416,7 @@ class Event(EventMixin, LoggedModel):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
return {
|
||||
p.module: p for p in get_all_plugins(self)
|
||||
p.module: p for p in get_all_plugins(event=self)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
|
||||
@@ -1401,12 +1435,20 @@ class Event(EventMixin, LoggedModel):
|
||||
self.plugins = ",".join(modules)
|
||||
|
||||
def enable_plugin(self, module, allow_restricted=frozenset()):
|
||||
"""
|
||||
Adds a plugin to the list of plugins, calling its ``installed`` hook (if available).
|
||||
It is the caller's responsibility to save the event object.
|
||||
"""
|
||||
plugins_active = self.get_plugins()
|
||||
if module not in plugins_active:
|
||||
plugins_active.append(module)
|
||||
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
|
||||
|
||||
def disable_plugin(self, module):
|
||||
"""
|
||||
Adds a plugin to the list of plugins, calling its ``uninstalled`` hook (if available).
|
||||
It is the caller's responsibility to save the event object.
|
||||
"""
|
||||
plugins_active = self.get_plugins()
|
||||
if module in plugins_active:
|
||||
plugins_active.remove(module)
|
||||
|
||||
@@ -195,20 +195,21 @@ class GiftCardTransaction(models.Model):
|
||||
return response
|
||||
|
||||
if self.order_id:
|
||||
if not self.text:
|
||||
if not customer_facing:
|
||||
return format_html(
|
||||
'<a href="{}">{}</a>',
|
||||
reverse(
|
||||
"control:event.order",
|
||||
kwargs={
|
||||
"event": self.order.event.slug,
|
||||
"organizer": self.order.event.organizer.slug,
|
||||
"code": self.order.code,
|
||||
}
|
||||
),
|
||||
self.order.full_code
|
||||
)
|
||||
if not customer_facing:
|
||||
return format_html(
|
||||
'<a href="{}">{}</a> {}',
|
||||
reverse(
|
||||
"control:event.order",
|
||||
kwargs={
|
||||
"event": self.order.event.slug,
|
||||
"organizer": self.order.event.organizer.slug,
|
||||
"code": self.order.code,
|
||||
}
|
||||
),
|
||||
self.order.full_code,
|
||||
self.text or "",
|
||||
)
|
||||
elif not self.text:
|
||||
return self.order.full_code
|
||||
else:
|
||||
return self.text
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import string
|
||||
import warnings
|
||||
from decimal import Decimal
|
||||
|
||||
import pycountry
|
||||
@@ -42,7 +43,8 @@ from django.db.models.functions import Cast
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import pgettext
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
@@ -110,6 +112,21 @@ class Invoice(models.Model):
|
||||
:param file: The filename of the rendered invoice
|
||||
:type file: File
|
||||
"""
|
||||
TRANSMISSION_STATUS_PENDING = "pending"
|
||||
TRANSMISSION_STATUS_INFLIGHT = "inflight"
|
||||
TRANSMISSION_STATUS_COMPLETED = "completed"
|
||||
TRANSMISSION_STATUS_FAILED = "failed"
|
||||
TRANSMISSION_STATUS_UNKNOWN = "unknown"
|
||||
TRANSMISSION_STATUS_TESTMODE_IGNORED = "testmode_ignored"
|
||||
TRANSMISSION_STATUS_CHOICES = (
|
||||
(TRANSMISSION_STATUS_PENDING, _("pending transmission")),
|
||||
(TRANSMISSION_STATUS_INFLIGHT, _("currently being transmitted")),
|
||||
(TRANSMISSION_STATUS_COMPLETED, _("transmitted")),
|
||||
(TRANSMISSION_STATUS_FAILED, _("failed")),
|
||||
(TRANSMISSION_STATUS_UNKNOWN, _("unknown")),
|
||||
(TRANSMISSION_STATUS_TESTMODE_IGNORED, _("not transmitted due to test mode")),
|
||||
)
|
||||
|
||||
order = models.ForeignKey('Order', related_name='invoices', db_index=True, on_delete=models.CASCADE)
|
||||
organizer = models.ForeignKey('Organizer', related_name='invoices', db_index=True, on_delete=models.PROTECT)
|
||||
event = models.ForeignKey('Event', related_name='invoices', db_index=True, on_delete=models.CASCADE)
|
||||
@@ -131,6 +148,7 @@ class Invoice(models.Model):
|
||||
|
||||
invoice_to = models.TextField()
|
||||
invoice_to_company = models.TextField(null=True)
|
||||
invoice_to_is_business = models.BooleanField(null=True)
|
||||
invoice_to_name = models.TextField(null=True)
|
||||
invoice_to_street = models.TextField(null=True)
|
||||
invoice_to_zipcode = models.CharField(max_length=190, null=True)
|
||||
@@ -139,9 +157,11 @@ class Invoice(models.Model):
|
||||
invoice_to_country = FastCountryField(null=True)
|
||||
invoice_to_vat_id = models.TextField(null=True)
|
||||
invoice_to_beneficiary = models.TextField(null=True)
|
||||
invoice_to_transmission_info = models.JSONField(null=True, blank=True)
|
||||
internal_reference = models.TextField(blank=True)
|
||||
custom_field = models.CharField(max_length=255, null=True)
|
||||
|
||||
created = models.DateTimeField(auto_now_add=True, null=True) # null for backwards compatibility
|
||||
date = models.DateField(default=today)
|
||||
locale = models.CharField(max_length=50, default='en')
|
||||
introductory_text = models.TextField(blank=True)
|
||||
@@ -158,16 +178,31 @@ class Invoice(models.Model):
|
||||
|
||||
shredded = models.BooleanField(default=False)
|
||||
|
||||
# The field sent_to_organizer records whether this invocie was already sent to the organizer by a configured
|
||||
# The field sent_to_organizer records whether this invoice was already sent to the organizer by a configured
|
||||
# mechanism such as email.
|
||||
# NULL: The cronjob that handles sending did not yet run.
|
||||
# True: The invoice was sent.
|
||||
# False: The invoice wasn't sent and never will, because sending was not configured at the time of the check.
|
||||
sent_to_organizer = models.BooleanField(null=True, blank=True)
|
||||
|
||||
sent_to_customer = models.DateTimeField(null=True, blank=True)
|
||||
transmission_type = models.CharField(
|
||||
max_length=255,
|
||||
default="email",
|
||||
)
|
||||
transmission_provider = models.CharField(
|
||||
max_length=255,
|
||||
null=True, blank=True,
|
||||
)
|
||||
transmission_status = models.CharField(
|
||||
max_length=255,
|
||||
choices=TRANSMISSION_STATUS_CHOICES,
|
||||
default=TRANSMISSION_STATUS_UNKNOWN,
|
||||
)
|
||||
transmission_date = models.DateTimeField(null=True, blank=True)
|
||||
transmission_info = models.JSONField(null=True, blank=True)
|
||||
|
||||
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
|
||||
plugin_data = models.JSONField(default=dict)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
@@ -323,6 +358,35 @@ class Invoice(models.Model):
|
||||
def __str__(self):
|
||||
return self.full_invoice_no
|
||||
|
||||
@property
|
||||
def regenerate_allowed(self):
|
||||
return self.transmission_status in (
|
||||
Invoice.TRANSMISSION_STATUS_UNKNOWN,
|
||||
Invoice.TRANSMISSION_STATUS_PENDING,
|
||||
Invoice.TRANSMISSION_STATUS_FAILED,
|
||||
) and self.event.settings.invoice_regenerate_allowed
|
||||
|
||||
@property
|
||||
def transmission_type_instance(self):
|
||||
from pretix.base.invoicing.transmission import transmission_types
|
||||
return transmission_types.get(identifier=self.transmission_type)[0]
|
||||
|
||||
def set_transmission_failed(self, provider, data):
|
||||
self.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
|
||||
self.transmission_date = now()
|
||||
if not self.transmission_provider and provider:
|
||||
self.transmission_provider = provider
|
||||
self.save(update_fields=["transmission_status", "transmission_date", "transmission_provider"])
|
||||
self.order.log_action(
|
||||
"pretix.event.order.invoice.sending_failed",
|
||||
data={
|
||||
"full_invoice_no": self.full_invoice_no,
|
||||
"transmission_provider": provider,
|
||||
"transmission_type": self.transmission_type,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class InvoiceLine(models.Model):
|
||||
"""
|
||||
@@ -342,10 +406,10 @@ class InvoiceLine(models.Model):
|
||||
:type tax_name: str
|
||||
:param subevent: The subevent this line refers to
|
||||
:type subevent: SubEvent
|
||||
:param event_date_from: Event date of the (sub)event at the time the invoice was created
|
||||
:type event_date_from: datetime
|
||||
:param event_date_to: Event end date of the (sub)event at the time the invoice was created
|
||||
:type event_date_to: datetime
|
||||
:param period_start: Start if service period invoiced
|
||||
:type period_start: datetime
|
||||
:param period_end: End of service period invoiced
|
||||
:type period_end: datetime
|
||||
:param event_location: Event location of the (sub)event at the time the invoice was created
|
||||
:type event_location: str
|
||||
:param item: The item this line refers to
|
||||
@@ -364,8 +428,8 @@ class InvoiceLine(models.Model):
|
||||
tax_name = models.CharField(max_length=190)
|
||||
tax_code = models.CharField(max_length=190, null=True, blank=True)
|
||||
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
|
||||
event_date_from = models.DateTimeField(null=True)
|
||||
event_date_to = models.DateTimeField(null=True)
|
||||
period_start = models.DateTimeField(null=True)
|
||||
period_end = models.DateTimeField(null=True)
|
||||
event_location = models.TextField(null=True, blank=True)
|
||||
item = models.ForeignKey('Item', null=True, blank=True, on_delete=models.PROTECT)
|
||||
variation = models.ForeignKey('ItemVariation', null=True, blank=True, on_delete=models.PROTECT)
|
||||
@@ -382,3 +446,35 @@ class InvoiceLine(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return 'Line {} of invoice {}'.format(self.position, self.invoice)
|
||||
|
||||
@property
|
||||
def event_date_from(self):
|
||||
warnings.warn(
|
||||
'InvoiceLine.event_date_from is deprecated, use period_start instead,',
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
return self.period_start
|
||||
|
||||
@event_date_from.setter
|
||||
def event_date_from(self, value):
|
||||
warnings.warn(
|
||||
'InvoiceLine.event_date_from is deprecated, use period_start instead,',
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
self.period_start = value
|
||||
|
||||
@property
|
||||
def event_date_to(self):
|
||||
warnings.warn(
|
||||
'InvoiceLine.event_date_to is deprecated, use period_end instead,',
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
return self.period_end
|
||||
|
||||
@event_date_to.setter
|
||||
def event_date_to(self, value):
|
||||
warnings.warn(
|
||||
'InvoiceLine.event_date_to is deprecated, use period_end instead,',
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
self.period_to = value
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user