forked from CGM_Public/pretix_original
Compare commits
609 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b66a35df7a | ||
|
|
2e1347cf9a | ||
|
|
8d1c9e44fc | ||
|
|
a187a02daa | ||
|
|
b9d100b5a8 | ||
|
|
f1e097c1b1 | ||
|
|
91a5b1546a | ||
|
|
3532f9c5a9 | ||
|
|
884e54180a | ||
|
|
e17ddb0cc8 | ||
|
|
55edc8a3d6 | ||
|
|
bd79a93737 | ||
|
|
12ab260eb1 | ||
|
|
30f0318de6 | ||
|
|
52e072e68f | ||
|
|
f25bb571b9 | ||
|
|
ae71492902 | ||
|
|
57375eb9b6 | ||
|
|
0657ef2e0c | ||
|
|
f63907fb16 | ||
|
|
e266d3808f | ||
|
|
180f9a356f | ||
|
|
480b71bd50 | ||
|
|
79839e3735 | ||
|
|
6ba5c58556 | ||
|
|
a5e3bab107 | ||
|
|
4dcce70ab3 | ||
|
|
8a5332f415 | ||
|
|
58ce1cbab7 | ||
|
|
27c3e5d875 | ||
|
|
caac517c0d | ||
|
|
58b9052164 | ||
|
|
2d223a9e11 | ||
|
|
fe37ab9286 | ||
|
|
95cc661a05 | ||
|
|
9a98d16949 | ||
|
|
50ba019a07 | ||
|
|
7d3e9b1777 | ||
|
|
f82640d763 | ||
|
|
d84cd71a5c | ||
|
|
74105ddd53 | ||
|
|
3a2f915ac9 | ||
|
|
9024a552a9 | ||
|
|
bae9fab2c4 | ||
|
|
ee3cd6d465 | ||
|
|
ccdcd380fa | ||
|
|
3c0f0434cd | ||
|
|
58dba57bef | ||
|
|
9178aef323 | ||
|
|
e9d696ea5e | ||
|
|
983ffdd8a8 | ||
|
|
294d47ccfc | ||
|
|
a14b1a5a14 | ||
|
|
a28c5f71c9 | ||
|
|
35bd9d1c22 | ||
|
|
b070fc0297 | ||
|
|
f7fd3596a6 | ||
|
|
3b4f758c82 | ||
|
|
df8c8f2063 | ||
|
|
ebb6b5b469 | ||
|
|
16ad39bb16 | ||
|
|
6ca65edde9 | ||
|
|
02684a0fcd | ||
|
|
141ba6e50d | ||
|
|
6681eb1a27 | ||
|
|
2b515ea30c | ||
|
|
7997882e24 | ||
|
|
a8190258a4 | ||
|
|
9376a26709 | ||
|
|
d8e2e0e217 | ||
|
|
f9c942bc6f | ||
|
|
f9b7696366 | ||
|
|
2143135285 | ||
|
|
54146bb9e8 | ||
|
|
d1f702cafd | ||
|
|
54e7b8da89 | ||
|
|
25af386d87 | ||
|
|
51fa9e78dd | ||
|
|
6cf244bb4b | ||
|
|
6a0e3b1b46 | ||
|
|
571b0e9aa8 | ||
|
|
8075d3e385 | ||
|
|
411f5c358f | ||
|
|
94d3eff799 | ||
|
|
a073d66213 | ||
|
|
7b2dda9cd9 | ||
|
|
e9c66f5bb1 | ||
|
|
24aa8fc033 | ||
|
|
ee74f75913 | ||
|
|
b484675aeb | ||
|
|
88379d7c25 | ||
|
|
4e8bdb4427 | ||
|
|
f68c29ca95 | ||
|
|
eb7154a55b | ||
|
|
ed19cc99f3 | ||
|
|
51ae1e5e33 | ||
|
|
132f8d8cb3 | ||
|
|
cd4b4b98b8 | ||
|
|
07e0ffd4f3 | ||
|
|
1d2a6d55b9 | ||
|
|
33b893b0ba | ||
|
|
03ebe0e528 | ||
|
|
28797b8cc6 | ||
|
|
691ba3a1a7 | ||
|
|
656673ccde | ||
|
|
7073622ab3 | ||
|
|
b8f71d2428 | ||
|
|
2a8bdc29f4 | ||
|
|
a6da1bb4e9 | ||
|
|
d1e67d38d9 | ||
|
|
a5214d459c | ||
|
|
9ad2891d17 | ||
|
|
4b2d55a2fb | ||
|
|
5cfed32d61 | ||
|
|
06ccd83921 | ||
|
|
ba417b6e3c | ||
|
|
f7ed0236f3 | ||
|
|
4491b80786 | ||
|
|
37bcb520cc | ||
|
|
782e957c3a | ||
|
|
953890c269 | ||
|
|
60d9c1080a | ||
|
|
364e7cefda | ||
|
|
33accf3250 | ||
|
|
be4d9ac00e | ||
|
|
8ca5e4dd54 | ||
|
|
1394cf3148 | ||
|
|
de58b35bf4 | ||
|
|
490b421d53 | ||
|
|
61d45f26dd | ||
|
|
a8b0475c6d | ||
|
|
31cf94eb02 | ||
|
|
dc0590ea91 | ||
|
|
bc5e5d0a27 | ||
|
|
0fc448fbd3 | ||
|
|
67d5c1ccad | ||
|
|
779ad11640 | ||
|
|
70e9d9faad | ||
|
|
51f5b0645a | ||
|
|
b3436c1a93 | ||
|
|
9eef5d5d6d | ||
|
|
e139de3c19 | ||
|
|
74f861bd48 | ||
|
|
35c02f35d7 | ||
|
|
d5c0b0f71d | ||
|
|
6c701d66b1 | ||
|
|
8d62b509a2 | ||
|
|
9e0b97e88e | ||
|
|
28a5519881 | ||
|
|
363826e294 | ||
|
|
eb8ea6d477 | ||
|
|
77be4d835b | ||
|
|
c6390520a7 | ||
|
|
594803ec17 | ||
|
|
32ce3a4319 | ||
|
|
d3f01832fe | ||
|
|
bba702489d | ||
|
|
85fe7e55be | ||
|
|
92c9216fbd | ||
|
|
db63e20708 | ||
|
|
e2ce35a85b | ||
|
|
d39964b021 | ||
|
|
59beba5069 | ||
|
|
1bd3a63959 | ||
|
|
1d644e90c9 | ||
|
|
e0e66c903e | ||
|
|
bc08bdebb5 | ||
|
|
edd92ac34d | ||
|
|
f1bce0c08b | ||
|
|
68c24ebea3 | ||
|
|
d22a7844ea | ||
|
|
6238e1df98 | ||
|
|
6acca4c4ba | ||
|
|
1a9f6e49d4 | ||
|
|
efa1d2683e | ||
|
|
9b39d34f81 | ||
|
|
96c5c8c4ff | ||
|
|
3254ac36a2 | ||
|
|
52d10957a1 | ||
|
|
f9d4669423 | ||
|
|
6e220cbbd8 | ||
|
|
036a555374 | ||
|
|
861a41c95f | ||
|
|
e2abc19fe3 | ||
|
|
97fc226e07 | ||
|
|
d73c98bff0 | ||
|
|
aa186f7a09 | ||
|
|
1b434b40d2 | ||
|
|
71475c5863 | ||
|
|
71b544d951 | ||
|
|
b0685437f1 | ||
|
|
2d99828eab | ||
|
|
2ffc1b8eaf | ||
|
|
893f47d365 | ||
|
|
7de1fca2f4 | ||
|
|
c6b18b31a1 | ||
|
|
ecc9c7f39f | ||
|
|
b9aba9cf56 | ||
|
|
33f0892052 | ||
|
|
4bf3d48549 | ||
|
|
32aa4b4f3e | ||
|
|
e1992bb99f | ||
|
|
45e98546d6 | ||
|
|
c7774dfdb8 | ||
|
|
6c582b8f8c | ||
|
|
5f82db3949 | ||
|
|
2b818f42cd | ||
|
|
b19df33dda | ||
|
|
dba8761bc5 | ||
|
|
0311c0251a | ||
|
|
5b99bf3623 | ||
|
|
4137e0fc1f | ||
|
|
b32c6033f1 | ||
|
|
de0e700fec | ||
|
|
00bc5f4fae | ||
|
|
6ef3603d9f | ||
|
|
2c7cefea35 | ||
|
|
a10b31cacb | ||
|
|
4e9e925b32 | ||
|
|
f4415cf906 | ||
|
|
bf4fcfd914 | ||
|
|
7021c178ab | ||
|
|
5d8e3e28d6 | ||
|
|
e89aaf4059 | ||
|
|
db270b3bf2 | ||
|
|
d8b78c3a7a | ||
|
|
67c448a29e | ||
|
|
5b7906f2a1 | ||
|
|
0612d42607 | ||
|
|
83f866034a | ||
|
|
b1fa214869 | ||
|
|
aa53b5235a | ||
|
|
61a13256a0 | ||
|
|
64e2336014 | ||
|
|
3411abd1e6 | ||
|
|
2a34e54fae | ||
|
|
9863dc35d6 | ||
|
|
690883a198 | ||
|
|
d8ded08a46 | ||
|
|
4aab5daa57 | ||
|
|
e87628c902 | ||
|
|
3c7bf46268 | ||
|
|
a1dacb1897 | ||
|
|
08d5626704 | ||
|
|
c8a1481f93 | ||
|
|
e7c4121745 | ||
|
|
35ddd8dd28 | ||
|
|
e2ec6eb156 | ||
|
|
42edc4c3aa | ||
|
|
1cb2f99f3a | ||
|
|
d4146e08b1 | ||
|
|
79ae9b6501 | ||
|
|
c23f71a19c | ||
|
|
53053f19e4 | ||
|
|
a42b2d76f6 | ||
|
|
51392f73a8 | ||
|
|
465a5b01b9 | ||
|
|
74a6004613 | ||
|
|
f9fc33eba1 | ||
|
|
363dc74c31 | ||
|
|
efb598e93a | ||
|
|
bcfaf2801d | ||
|
|
98db417fe6 | ||
|
|
a03ffd949e | ||
|
|
88ef46dee9 | ||
|
|
9bc6941c14 | ||
|
|
987da83894 | ||
|
|
d029d92a92 | ||
|
|
f1b07777bc | ||
|
|
db187a2537 | ||
|
|
e9a340d9ca | ||
|
|
6841a30d8f | ||
|
|
30b8c0f4b9 | ||
|
|
3e8f32e7e3 | ||
|
|
2b145e254b | ||
|
|
e5c2470fde | ||
|
|
2da93eba26 | ||
|
|
788f73d842 | ||
|
|
d86b3a2173 | ||
|
|
7be6046ed5 | ||
|
|
6b90689067 | ||
|
|
815816b9d6 | ||
|
|
3199687fe4 | ||
|
|
6d8b8c6346 | ||
|
|
8a850773f4 | ||
|
|
2a10f875e4 | ||
|
|
d8d2a21bda | ||
|
|
18eb468d8e | ||
|
|
2842b0e720 | ||
|
|
42936a931b | ||
|
|
a6c72abe75 | ||
|
|
df3e6f4b9a | ||
|
|
8ef99ba828 | ||
|
|
e8e5f5c7bf | ||
|
|
f0128429e4 | ||
|
|
cc8e5a7f83 | ||
|
|
d4d3928146 | ||
|
|
cc4602c308 | ||
|
|
2bc0dd6076 | ||
|
|
f286c5af28 | ||
|
|
ec27ed198b | ||
|
|
2ee0f684c5 | ||
|
|
951386b32c | ||
|
|
f498e8fafa | ||
|
|
b79947fba4 | ||
|
|
ef600ceddb | ||
|
|
13bf975dd5 | ||
|
|
8e56c8dcf7 | ||
|
|
a42b31560c | ||
|
|
e15e7a5877 | ||
|
|
e7384f7e85 | ||
|
|
840b30c3c2 | ||
|
|
1adabec989 | ||
|
|
171bea59df | ||
|
|
3c4b086992 | ||
|
|
6a4e6e227c | ||
|
|
9c3abc5338 | ||
|
|
91b2d7989a | ||
|
|
c5a80e6daf | ||
|
|
37ce9fa9af | ||
|
|
64fe3d772c | ||
|
|
5c82781fcc | ||
|
|
0d70e3c8e3 | ||
|
|
85a7f0c0cc | ||
|
|
6d0e1097e6 | ||
|
|
c557087252 | ||
|
|
62796cdc5f | ||
|
|
bbe5f9bd98 | ||
|
|
003ccd83bf | ||
|
|
f8f6dc4a51 | ||
|
|
cddf716784 | ||
|
|
ee495f2777 | ||
|
|
5bdc9011c1 | ||
|
|
c6ea30ec1e | ||
|
|
f9341b4d47 | ||
|
|
2205e57650 | ||
|
|
ad8fdd6935 | ||
|
|
02e936ee7a | ||
|
|
45a6923220 | ||
|
|
e4417305a2 | ||
|
|
bc5d0bea00 | ||
|
|
dbce9b0395 | ||
|
|
2eb88840bd | ||
|
|
4838835b1b | ||
|
|
ab452bd9e3 | ||
|
|
ae298bddb8 | ||
|
|
9ad4607d26 | ||
|
|
b3684377cd | ||
|
|
441badfdbd | ||
|
|
0d242a0304 | ||
|
|
2fac8592d4 | ||
|
|
58b1a2f115 | ||
|
|
420d44e909 | ||
|
|
e0063fce52 | ||
|
|
21ef6c7950 | ||
|
|
651f429ffb | ||
|
|
66dd7c448b | ||
|
|
e9b4205145 | ||
|
|
6dedea1025 | ||
|
|
348ed4e909 | ||
|
|
091b3358e4 | ||
|
|
186e2a6b9a | ||
|
|
198b90972c | ||
|
|
4989b6235c | ||
|
|
4cfebab11c | ||
|
|
fe944ec643 | ||
|
|
9d92c7b10f | ||
|
|
3b810a3a76 | ||
|
|
7860417177 | ||
|
|
1438edb3c8 | ||
|
|
a17720062b | ||
|
|
37ab45b352 | ||
|
|
6be212df8c | ||
|
|
b4e85780f4 | ||
|
|
a9732c4788 | ||
|
|
273316be25 | ||
|
|
0f3b269931 | ||
|
|
4462054d0e | ||
|
|
ec53022cc8 | ||
|
|
0b65b18459 | ||
|
|
2fac03f47b | ||
|
|
750d5eda48 | ||
|
|
f2cd9a2002 | ||
|
|
874b38db17 | ||
|
|
0f58e1c396 | ||
|
|
36e0afc09e | ||
|
|
7164124a70 | ||
|
|
887d8832c0 | ||
|
|
beb144f9a0 | ||
|
|
6d1dea7922 | ||
|
|
cb531a7a6a | ||
|
|
d5820d74d3 | ||
|
|
b686978074 | ||
|
|
c372bffc57 | ||
|
|
282c6108bf | ||
|
|
f2437c7ff7 | ||
|
|
dd0b6e6647 | ||
|
|
f3128591d8 | ||
|
|
d395db8142 | ||
|
|
0c82e92882 | ||
|
|
db0c13a3c2 | ||
|
|
19a2f4163a | ||
|
|
76526465c0 | ||
|
|
d0d0f9aa4c | ||
|
|
482f6b1eb8 | ||
|
|
327418299a | ||
|
|
5dfd1e6337 | ||
|
|
bc01124584 | ||
|
|
c0df418265 | ||
|
|
af06f6fc38 | ||
|
|
4c0e8f69ea | ||
|
|
243e4ac4c8 | ||
|
|
b931d27486 | ||
|
|
2810e2a760 | ||
|
|
04465393b2 | ||
|
|
4c9032f2a8 | ||
|
|
cae2bb944a | ||
|
|
724e745b8d | ||
|
|
f4cead1c20 | ||
|
|
7cab1924bb | ||
|
|
641148fecc | ||
|
|
9b3860e5fd | ||
|
|
cb9d4c10df | ||
|
|
84105b9585 | ||
|
|
3f38caeb24 | ||
|
|
eae552e474 | ||
|
|
f27c10c2ac | ||
|
|
abd237b969 | ||
|
|
99c61c9060 | ||
|
|
246f307e21 | ||
|
|
1f672e7df2 | ||
|
|
b261a2041a | ||
|
|
2d37c6d94d | ||
|
|
e75ae80fb5 | ||
|
|
73ec5bac79 | ||
|
|
46166159b0 | ||
|
|
598693fab2 | ||
|
|
2420d884fc | ||
|
|
f95005a8d4 | ||
|
|
e773096df3 | ||
|
|
c42905421d | ||
|
|
46c2e28def | ||
|
|
07bc3df6d3 | ||
|
|
2992c4c48a | ||
|
|
c53718381e | ||
|
|
98e5f0b95d | ||
|
|
7f11f06f3f | ||
|
|
949057a9cc | ||
|
|
edd643cc32 | ||
|
|
5f15ebc46f | ||
|
|
3415fd947a | ||
|
|
a70a42c273 | ||
|
|
697cdfd5c9 | ||
|
|
d8a7de8b23 | ||
|
|
9f7f0e74ff | ||
|
|
7ef289da45 | ||
|
|
e82bc732a3 | ||
|
|
4636ccac3b | ||
|
|
e3518bfb4b | ||
|
|
b2471169af | ||
|
|
487418678c | ||
|
|
d4795868d6 | ||
|
|
45af18a23d | ||
|
|
a6de586b80 | ||
|
|
e6859fa82b | ||
|
|
2d5e14e517 | ||
|
|
7219575b84 | ||
|
|
991e4127f6 | ||
|
|
420649e10a | ||
|
|
0d02e2fe8c | ||
|
|
afdba9f268 | ||
|
|
394f7e04c3 | ||
|
|
c3a5cef051 | ||
|
|
47b7bcbfca | ||
|
|
2cd1345035 | ||
|
|
c24ce551ba | ||
|
|
0bb6e460e8 | ||
|
|
26257f0829 | ||
|
|
6badb342bf | ||
|
|
865a70d5d5 | ||
|
|
df1c0d4f3a | ||
|
|
adb982c451 | ||
|
|
94ba26d841 | ||
|
|
45d5487eb5 | ||
|
|
38f5f75a1b | ||
|
|
90f881c48e | ||
|
|
c4b18a4c81 | ||
|
|
8e2ef604f7 | ||
|
|
970e4f6d52 | ||
|
|
59c9731bae | ||
|
|
8afd09a647 | ||
|
|
fa375950a7 | ||
|
|
ccdfa716c0 | ||
|
|
08ffa17e01 | ||
|
|
7f8c91ec9d | ||
|
|
dde3a53e09 | ||
|
|
73bc3259e8 | ||
|
|
34767a2029 | ||
|
|
85bd1a0d44 | ||
|
|
1ad4b6019e | ||
|
|
a778675857 | ||
|
|
e5a980aef4 | ||
|
|
e8338a2941 | ||
|
|
289bbf84a9 | ||
|
|
f13dbb85cb | ||
|
|
49e706a580 | ||
|
|
ca7d55082b | ||
|
|
516fab52da | ||
|
|
ddf6af278c | ||
|
|
07b4b8c473 | ||
|
|
a0af0cfb06 | ||
|
|
3eb86a371a | ||
|
|
ae7175c00b | ||
|
|
4286176e73 | ||
|
|
0f53ab67df | ||
|
|
0d6db082ca | ||
|
|
2a9b8baa98 | ||
|
|
6bdaab5319 | ||
|
|
489d5e3d01 | ||
|
|
86e2cf2786 | ||
|
|
f075dbc78e | ||
|
|
10500ee6a9 | ||
|
|
6c766d872d | ||
|
|
c8b1206e61 | ||
|
|
424d1489bf | ||
|
|
b3e567a188 | ||
|
|
66e42f66e5 | ||
|
|
786fbc6e29 | ||
|
|
f2ff5d7510 | ||
|
|
0bcc4de2de | ||
|
|
586e7cc997 | ||
|
|
bf33cc1499 | ||
|
|
faff7b4166 | ||
|
|
44263a17e6 | ||
|
|
87b4d1aaed | ||
|
|
0d12c1589a | ||
|
|
96ddd8ce4e | ||
|
|
dc2da88220 | ||
|
|
b8796b0632 | ||
|
|
961b1d4efa | ||
|
|
ac674565cf | ||
|
|
301e9d1d48 | ||
|
|
81e08021db | ||
|
|
da1beac49c | ||
|
|
a81fc5bfe0 | ||
|
|
eb3ec0f99a | ||
|
|
7e98846315 | ||
|
|
22f9178617 | ||
|
|
ef73abc3f6 | ||
|
|
ac6e6a526e | ||
|
|
8185c6a0d6 | ||
|
|
a7b294fc61 | ||
|
|
b81f07b237 | ||
|
|
67bdb0ec1f | ||
|
|
7b6ff01740 | ||
|
|
83f5182db2 | ||
|
|
ee2050b8f9 | ||
|
|
185fc6c73d | ||
|
|
a21ea34944 | ||
|
|
130ba3c217 | ||
|
|
dd39131942 | ||
|
|
295ad9e9c3 | ||
|
|
a0969dc7fa | ||
|
|
81a2e0c71c | ||
|
|
71f69c9afb | ||
|
|
cfff6f1605 | ||
|
|
84ccaed94a | ||
|
|
ec61e07ab6 | ||
|
|
4d5fb67b02 | ||
|
|
c885807fa5 | ||
|
|
88ec54809a | ||
|
|
5f96152d57 | ||
|
|
a9b08660c6 | ||
|
|
68e901f76d | ||
|
|
e10083379c | ||
|
|
d705102cbb | ||
|
|
122fda27c4 | ||
|
|
b83752005a | ||
|
|
7fc926f23e | ||
|
|
d90686f352 | ||
|
|
8523f4dfa2 | ||
|
|
9c13676349 | ||
|
|
19ee8e9802 | ||
|
|
bbe5ff249b | ||
|
|
7470cda17f | ||
|
|
a4d50ae4c5 | ||
|
|
e0d7a9d2da | ||
|
|
2159a65643 | ||
|
|
3f05c92602 | ||
|
|
d764cdb338 | ||
|
|
7b7bd67ae9 | ||
|
|
dc502618dd | ||
|
|
cb6b4c96f8 | ||
|
|
de5f094f73 | ||
|
|
6b85e89e62 | ||
|
|
8cfc8bc152 | ||
|
|
27990b3fbb | ||
|
|
307ee36e52 | ||
|
|
f95e8f374d | ||
|
|
d10cbd07a7 | ||
|
|
5519643782 | ||
|
|
2c91a17927 | ||
|
|
875d79536b | ||
|
|
4bf0d2d229 | ||
|
|
068983004a | ||
|
|
0365a1c68d | ||
|
|
5024fae5ed | ||
|
|
affc6254a8 | ||
|
|
bb956c13ba |
17
.travis.yml
17
.travis.yml
@@ -1,4 +1,5 @@
|
||||
language: python
|
||||
dist: xenial
|
||||
sudo: false
|
||||
install:
|
||||
- pip install -U pip wheel setuptools
|
||||
@@ -12,23 +13,23 @@ services:
|
||||
- postgresql
|
||||
matrix:
|
||||
include:
|
||||
- python: 3.6
|
||||
- python: 3.7
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
|
||||
- python: 3.6
|
||||
- python: 3.7
|
||||
env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.6
|
||||
- python: 3.7
|
||||
env: JOB=style
|
||||
- python: 3.6
|
||||
- python: 3.7
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.6
|
||||
- python: 3.7
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.6
|
||||
- python: 3.7
|
||||
env: JOB=plugins
|
||||
- python: 3.6
|
||||
- python: 3.7
|
||||
env: JOB=doc-spelling
|
||||
- python: 3.6
|
||||
- python: 3.7
|
||||
env: JOB=translation-spelling
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
|
||||
@@ -125,6 +125,8 @@ Example::
|
||||
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
|
||||
turns on some optimizations/special case handlers. Default: ``False``
|
||||
|
||||
.. _`config-replica`:
|
||||
|
||||
Database replica settings
|
||||
-------------------------
|
||||
|
||||
@@ -142,6 +144,8 @@ Example::
|
||||
[replica]
|
||||
host=192.168.0.2
|
||||
|
||||
.. _`config-urls`:
|
||||
|
||||
URLs
|
||||
----
|
||||
|
||||
@@ -269,6 +273,24 @@ to speed up various operations::
|
||||
If redis is not configured, pretix will store sessions and locks in the database. If memcached
|
||||
is configured, memcached will be used for caching instead of redis.
|
||||
|
||||
Translations
|
||||
------------
|
||||
|
||||
pretix comes with a number of translations. Some of them are marked as "incubating", which means
|
||||
they can usually only be selected in development mode. If you want to use them nevertheless, you
|
||||
can activate them like this::
|
||||
|
||||
[languages]
|
||||
allow_incubating=pt-br,da
|
||||
|
||||
You can also tell pretix about additional paths where it will search for translations::
|
||||
|
||||
[languages]
|
||||
path=/path/to/my/translations
|
||||
|
||||
For a given language (e.g. ``pt-br``), pretix will then look in the
|
||||
specific sub-folder, e.g. ``/path/to/my/translations/pt_BR/LC_MESSAGES/django.po``.
|
||||
|
||||
Celery task queue
|
||||
-----------------
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@ This documentation is for everyone who wants to install pretix on a server.
|
||||
installation/index
|
||||
config
|
||||
maintainance
|
||||
scaling
|
||||
|
||||
@@ -58,16 +58,29 @@ Database
|
||||
--------
|
||||
|
||||
Next, we need a database and a database user. We can create these with any kind of database managing tool or directly on
|
||||
our database's shell, e.g. for MySQL::
|
||||
our database's shell. For PostgreSQL, we would do::
|
||||
|
||||
$ mysql -u root -p
|
||||
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
mysql> GRANT ALL PRIVILEGES ON pretix.* TO pretix@'localhost' IDENTIFIED BY '*********';
|
||||
mysql> FLUSH PRIVILEGES;
|
||||
# sudo -u postgres createuser -P pretix
|
||||
# sudo -u postgres createdb -O pretix pretix
|
||||
|
||||
Replace the asterisks with a password of your own. For MySQL, we will use a unix domain socket to connect to the
|
||||
database. For PostgreSQL, be sure to configure the interface binding and your firewall so that the docker container
|
||||
can reach PostgreSQL.
|
||||
Make sure that your database listens on the network. If PostgreSQL on the same same host as docker, but not inside a docker container, we recommend that you just listen on the Docker interface by changing the following line in ``/etc/postgresql/<version>/main/postgresql.conf``::
|
||||
|
||||
listen_addresses = 'localhost,172.17.0.1'
|
||||
|
||||
You also need to add a new line to ``/etc/postgresql/<version>/main/pg_hba.conf`` to allow network connections to this user and database::
|
||||
|
||||
host pretix pretix 172.17.0.1/16 md5
|
||||
|
||||
Restart PostgreSQL after you changed these files::
|
||||
|
||||
# systemctl restart postgresql
|
||||
|
||||
If you have a firewall running, you should also make sure that port 5432 is reachable from the ``172.17.0.1/16`` subnet.
|
||||
|
||||
For MySQL, you can either also use network-based connections or mount the ``/var/run/mysqld/mysqld.sock`` socket into the docker container.
|
||||
When using MySQL, make sure you set the character set of the database to ``utf8mb4``, e.g. like this::
|
||||
|
||||
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
Redis
|
||||
-----
|
||||
@@ -114,13 +127,16 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
datadir=/data
|
||||
|
||||
[database]
|
||||
; Replace mysql with postgresql_psycopg2 for PostgreSQL
|
||||
backend=mysql
|
||||
; Replace postgresql with mysql for MySQL
|
||||
backend=postgresql
|
||||
name=pretix
|
||||
user=pretix
|
||||
; Replace with the password you chose above
|
||||
password=*********
|
||||
; Replace with host IP address for PostgreSQL
|
||||
host=/var/run/mysqld/mysqld.sock
|
||||
; In most docker setups, 172.17.0.1 is the address of the docker host. Adjuts
|
||||
; this to wherever your database is running, e.g. the name of a linked container
|
||||
; or of a mounted MySQL socket.
|
||||
host=172.17.0.1
|
||||
|
||||
[mail]
|
||||
; See config file documentation for more options
|
||||
@@ -164,14 +180,15 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
|
||||
-v /var/pretix-data:/data \
|
||||
-v /etc/pretix:/etc/pretix \
|
||||
-v /var/run/redis:/var/run/redis \
|
||||
-v /var/run/mysqld:/var/run/mysqld \
|
||||
pretix/standalone:stable all
|
||||
ExecStop=/usr/bin/docker stop %n
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
You can leave the MySQL socket volume out if you're using PostgreSQL. You can now run the following commands
|
||||
When using MySQL and socket mounting, you'll need the additional flag ``-v /var/run/mysqld:/var/run/mysqld`` in the command.
|
||||
|
||||
You can now run the following commands
|
||||
to enable and start the service::
|
||||
|
||||
# systemctl daemon-reload
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.. _`installation`:
|
||||
|
||||
Installation guide
|
||||
==================
|
||||
|
||||
|
||||
@@ -50,21 +50,23 @@ Database
|
||||
--------
|
||||
|
||||
Having the database server installed, we still need a database and a database user. We can create these with any kind
|
||||
of database managing tool or directly on our database's shell, e.g. for MySQL::
|
||||
of database managing tool or directly on our database's shell. For PostgreSQL, we would do::
|
||||
|
||||
$ mysql -u root -p
|
||||
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
mysql> GRANT ALL PRIVILEGES ON pretix.* TO pretix@'localhost' IDENTIFIED BY '*********';
|
||||
mysql> FLUSH PRIVILEGES;
|
||||
# sudo -u postgres createuser pretix
|
||||
# sudo -u postgres createdb -O pretix pretix
|
||||
|
||||
When using MySQL, make sure you set the character set of the database to ``utf8mb4``, e.g. like this::
|
||||
|
||||
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
Package dependencies
|
||||
--------------------
|
||||
|
||||
To build and run pretix, you will need the following debian packages::
|
||||
|
||||
# apt-get install git build-essential python-dev python-virtualenv python3 python3-pip \
|
||||
# apt-get install git build-essential python-dev python3-venv python3 python3-pip \
|
||||
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
||||
gettext libpq-dev libmysqlclient-dev libjpeg-dev libopenjp2-7-dev
|
||||
gettext libpq-dev libmariadbclient-dev libjpeg-dev libopenjp2-7-dev
|
||||
|
||||
Config file
|
||||
-----------
|
||||
@@ -85,13 +87,18 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
datadir=/var/pretix/data
|
||||
|
||||
[database]
|
||||
; Replace mysql with postgresql_psycopg2 for PostgreSQL
|
||||
backend=mysql
|
||||
; For MySQL, replace with "mysql"
|
||||
backend=postgresql
|
||||
name=pretix
|
||||
user=pretix
|
||||
password=*********
|
||||
; Replace with host IP address for PostgreSQL
|
||||
host=/var/run/mysqld/mysqld.sock
|
||||
; For MySQL, enter the user password. For PostgreSQL on the same host,
|
||||
; we don't need one because we can use peer authentification if our
|
||||
; PostgreSQL user matches our unix user.
|
||||
password=
|
||||
; For MySQL, use local socket, e.g. /var/run/mysqld/mysqld.sock
|
||||
; For a remote host, supply an IP address
|
||||
; For local postgres authentication, you can leave it empty
|
||||
host=
|
||||
|
||||
[mail]
|
||||
; See config file documentation for more options
|
||||
@@ -115,14 +122,14 @@ Now we will install pretix itself. The following steps are to be executed as the
|
||||
actually install pretix, we will create a virtual environment to isolate the python packages from your global
|
||||
python installation::
|
||||
|
||||
$ virtualenv -p python3 /var/pretix/venv
|
||||
$ python3 -m venv /var/pretix/venv
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install -U pip setuptools wheel
|
||||
|
||||
We now install pretix, its direct dependencies and gunicorn. Replace ``mysql`` with ``postgres`` in the following
|
||||
command if you're running PostgreSQL::
|
||||
We now install pretix, its direct dependencies and gunicorn. Replace ``postgres`` with ``mysql`` in the following
|
||||
command if you're running MySQL::
|
||||
|
||||
(venv)$ pip3 install "pretix[mysql]" gunicorn
|
||||
(venv)$ pip3 install "pretix[postgres]" gunicorn
|
||||
|
||||
Note that you need Python 3.5 or newer. You can find out your Python version using ``python -V``.
|
||||
|
||||
@@ -268,10 +275,10 @@ Updates
|
||||
.. warning:: While we try hard not to break things, **please perform a backup before every upgrade**.
|
||||
|
||||
To upgrade to a new pretix release, pull the latest code changes and run the following commands (again, replace
|
||||
``mysql`` with ``postgres`` if necessary)::
|
||||
``postgres`` with ``mysql`` if necessary)::
|
||||
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install -U pretix[mysql] gunicorn
|
||||
(venv)$ pip3 install -U pretix[postgres] gunicorn
|
||||
(venv)$ python -m pretix migrate
|
||||
(venv)$ python -m pretix rebuild
|
||||
(venv)$ python -m pretix updatestyles
|
||||
|
||||
236
doc/admin/scaling.rst
Normal file
236
doc/admin/scaling.rst
Normal file
@@ -0,0 +1,236 @@
|
||||
.. _`scaling`:
|
||||
|
||||
Scaling guide
|
||||
=============
|
||||
|
||||
Our :ref:`installation guide <installation>` only covers "small-scale" setups, by which we mostly mean
|
||||
setups that run on a **single (virtual) machine** and do not encounter large traffic peaks.
|
||||
|
||||
We do not offer an installation guide for larger-scale setups of pretix, mostly because we believe that
|
||||
there is no one-size-fits-all solution for this and the desired setup highly depends on your use case,
|
||||
the platform you run pretix on, and your technical capabilities. We do not recommend trying set up pretix
|
||||
in a multi-server environment if you do not already have experience with managing server clusters.
|
||||
|
||||
This document is intended to give you a general idea on what issues you will encounter when you scale up
|
||||
and what you should think of.
|
||||
|
||||
.. tip::
|
||||
|
||||
If you require more help on this, we're happy to help. Our pretix Enterprise support team has built
|
||||
and helped building, scaling and load-testing pretix installations at any scale and we're looking
|
||||
forward to work with you on fine-tuning your system. If you intend to sell **more than a thousand
|
||||
tickets in a very short amount of time**, we highly recommend reaching out and at least talking this
|
||||
through. Just get in touch at sales@pretix.eu!
|
||||
|
||||
Scaling reasons
|
||||
---------------
|
||||
|
||||
There's mainly two reasons to scale up a pretix installation beyond a single server:
|
||||
|
||||
* **Availability:** Distributing pretix over multiple servers can allow you to survive failure of one or more single machines, leading to a higher uptime and reliability of your system.
|
||||
|
||||
* **Traffic and throughput:** Distributing pretix over multiple servers can allow you to process more web requests and ticket sales at the same time.
|
||||
|
||||
You are very unlikely to require scaling for other reasons, such as having too much data in your database.
|
||||
|
||||
Components
|
||||
----------
|
||||
|
||||
A pretix installation usually consists of the following components which run performance-relevant processes:
|
||||
|
||||
* ``pretix-web`` is the Django-based web application that serves all user interaction.
|
||||
|
||||
* ``pretix-worker`` is a Celery-based application that processes tasks that should be run asynchronously outside of the web application process.
|
||||
|
||||
* A **SQL database** keeps all the important data and processes the actual transactions. We recommend using PostgreSQL, but MySQL/MariaDB works as well.
|
||||
|
||||
* A **web server** that terminates TLS and HTTP connections and forwards them to ``pretix-web``. In some cases, e.g. when serving static files, the web servers might return a response directly. We recommend using ``nginx``.
|
||||
|
||||
* A **redis** server responsible for the communication between ``pretix-web`` and ``pretix-worker``, as well as for caching.
|
||||
|
||||
* A directory of **media files** such as user-uploaded files or generated files (tickets, invoices, …) that are created and used by ``pretix-web``, ``pretix-worker`` and the web server.
|
||||
|
||||
In the following, we will discuss the scaling behavior of every component individually. In general, you can run all of the components
|
||||
on the same server, but you can just as well distribute every component to its own server, or even use multiple servers for some single
|
||||
components.
|
||||
|
||||
.. warning::
|
||||
|
||||
When setting up your system, don't forget about security. In a multi-server environment,
|
||||
you need to take special care to ensure that no unauthorized access to your database
|
||||
is possible through the network and that it's not easy to wiretap your connections. We
|
||||
recommend a rigorous use of firewalls and encryption on all communications. You can
|
||||
ensure this either on an application level (such as using the TLS support in your
|
||||
database) or on a network level with a VPN solution.
|
||||
|
||||
Web server
|
||||
""""""""""
|
||||
|
||||
Your web server is at the very front of your installation. It will need to absorb all of the traffic, and it should be able to
|
||||
at least show a decent error message, even when everything else fails. Luckily, web servers are really fast these days, so this
|
||||
can be achieved without too much work.
|
||||
|
||||
We recommend reading up on tuning your web server for high concurrency. For nginx, this means thinking about the number of worker
|
||||
processes and the number of connections each worker process accepts. Double-check that TLS session caching works, because TLS
|
||||
handshakes can get really expensive.
|
||||
|
||||
During a traffic peak, your web server will be able to make us of more CPU resources, while memory usage will stay comparatively low,
|
||||
so if you invest in more hardware here, invest in more and faster CPU cores.
|
||||
|
||||
Make sure that pretix' static files (such as CSS and JavaScript assets) as well as user-uploaded media files (event logos, etc)
|
||||
are served directly by your web server and your web server caches them in-memory (nginx does it by default) and sets useful
|
||||
headers for client-side caching. As an additional performance improvement, you can turn of access logging for these types of files.
|
||||
If you want, you can even farm out serving static files to a different web server entirely and :ref:`configure pretix to reference
|
||||
them from a different URL <config-urls>`.
|
||||
|
||||
.. tip::
|
||||
|
||||
If you expect *really high traffic* for your very popular event, you might want to do some rate limiting on this layer, or,
|
||||
if you want to ensure a fair and robust first-come-first-served experience and prefer letting users wait over showing them
|
||||
errors, consider a queuing solution. We're happy to provide you with such systems, just get in touch at sales@pretix.eu.
|
||||
|
||||
pretix-web
|
||||
""""""""""
|
||||
|
||||
The ``pretix-web`` process does not carry any internal state can be easily started on as many machines as you like, and you can
|
||||
use the load balancing features of your frontend web server to redirect to all of them.
|
||||
|
||||
You can adjust the number of processes in the ``gunicorn`` command line, and we recommend choosing roughly two times the number
|
||||
of CPU cores available. Under load, the memory consumption of ``pretix-web`` will stay comparatively constant, while the CPU usage
|
||||
will increase a lot. Therefore, if you can add more or faster CPU cores, you will be able to serve more users.
|
||||
|
||||
pretix-worker
|
||||
"""""""""""""
|
||||
|
||||
The ``pretix-worker`` process performs all operations that are not directly executed in the request-response-cycle of ``pretix-web``.
|
||||
Just like ``pretix-web`` you can easily start up as many instances as you want on different machines to share the work. As long as they
|
||||
all talk to the same redis server, they will all receive tasks from ``pretix-web``, work on them and post their result back.
|
||||
You can configure the number of threads that run tasks in parallel through the ``--concurrency`` command line option of ``celery``.
|
||||
|
||||
Just like ``pretix-web``, this process is mostly heavy on CPU, disk IO and network IO, although memory peaks can occur e.g. during the
|
||||
generation of large PDF files, so we recommend having some reserves here.
|
||||
|
||||
``pretix-worker`` performs a variety of tasks which are of different importance.
|
||||
Some of them are mission-critical and need to be run quickly even during high load (such as
|
||||
creating a cart or an order), others are irrelevant and can easily run later (such as
|
||||
distributing tickets on the waiting list). You can fine-tune the capacity you assign to each
|
||||
of these tasks by running ``pretix-worker`` processes that only work on a specific **queue**.
|
||||
For example, you could have three servers dedicated only to process order creations and one
|
||||
server dedicated only to sending emails. This allows you to set priorities and also protects
|
||||
you from e.g. a slow email server lowering your ticket throughput.
|
||||
|
||||
You can do so by specifying one or more queues on the ``celery`` command line of this process, such as ``celery -A pretix.celery_app worker -Q notifications,mail``. Currently,
|
||||
the following queues exist:
|
||||
|
||||
* ``checkout`` -- This queue handles everything related to carts and orders and thereby everything required to process a sale. This includes adding and deleting items from carts as well as creating and canceling orders.
|
||||
|
||||
* ``mail`` -- This queue handles sending of outgoing emails.
|
||||
|
||||
* ``notifications`` -- This queue handles the processing of any outgoing notifications, such as email notifications to admin users (except for the actual sending) or API notifications to registered webhooks.
|
||||
|
||||
* ``background`` -- This queue handles tasks that are expected to take long or have no human waiting for their result immediately, such as refreshing caches, re-generating CSS files, assigning tickets on the waiting list or parsing bank data files.
|
||||
|
||||
* ``default`` -- This queue handles everything else with "medium" or unassigned priority, most prominently the generation of files for tickets, invoices, badges, admin exports, etc.
|
||||
|
||||
Media files
|
||||
"""""""""""
|
||||
|
||||
Both ``pretix-web``, ``pretix-worker`` and in some cases your webserver need to work with
|
||||
media files. Media files are all files generated *at runtime* by the software. This can
|
||||
include files uploaded by the event organizers, such as the event logo, files uploaded by
|
||||
ticket buyers (if you use such features) or files generated by the software, such as
|
||||
ticket files, invoice PDFs, data exports or customized CSS files.
|
||||
|
||||
Those files are by default stored to the ``media/`` sub-folder of the data directory given
|
||||
in the ``pretix.cfg`` configuration file. Inside that ``media/`` folder, you will find a
|
||||
``pub/`` folder containing the subset of files that should be publicly accessible through
|
||||
the web server. Everything else only needs to be accessible by ``pretix-web`` and
|
||||
``pretix-worker`` themselves.
|
||||
|
||||
If you distribute ``pretix-web`` or ``pretix-worker`` across more than one machine, you
|
||||
**must** make sure that they all have access to a shared storage to read and write these
|
||||
files, otherwise you **will** run into errors with the user interface.
|
||||
|
||||
The easiest solution for this is probably to store them on a NFS server that you mount
|
||||
on each of the other servers.
|
||||
|
||||
Since we use Django's file storage mechanism internally, you can in theory also use a object-storage solution like Amazon S3, Ceph, or Minio to store these files, although we currently do not expose this through pretix' configuration file and this would require you to ship your own variant of ``pretix/settings.py`` and reference it through the ``DJANGO_SETTINGS_MODULE`` environment variable.
|
||||
|
||||
At pretix.eu, we use a custom-built `object storage cluster`_.
|
||||
|
||||
SQL database
|
||||
""""""""""""
|
||||
|
||||
One of the most critical parts of the whole setup is the SQL database -- and certainly the
|
||||
hardest to scale. Tuning relational databases is an art form, and while there's lots of
|
||||
material on it on the internet, there's not a single recipe that you can apply to every case.
|
||||
|
||||
As a general rule of thumb, the more resources you can give your databases, the better.
|
||||
Most databases will happily use all CPU cores available, but only use memory up to an amount
|
||||
you configure, so make sure to set this memory usage as high as you can afford. Having more
|
||||
memory available allows your database to make more use of caching, which is usually good.
|
||||
|
||||
Scaling your database to multiple machines needs to be treated with great caution. It's a
|
||||
good to have a replica of your database for availability reasons. In case your primary
|
||||
database server fails, you can easily switch over to the replica and continue working.
|
||||
|
||||
However, using database replicas for performance gains is much more complicated. When using
|
||||
replicated database systems, you are always trading in consistency or availability to get
|
||||
additional performance and the consequences of this can be subtle and it is important
|
||||
that you have a deep understanding of the semantics of your replication mechanism.
|
||||
|
||||
.. warning::
|
||||
|
||||
Using an off-the-shelf database proxy solution that redirects read queries to your
|
||||
replicas and write queries to your primary database **will lead to very nasty bugs.**
|
||||
|
||||
As an example, if you buy a ticket, pretix first needs to calculate how many tickets
|
||||
are left to sell. If this calculation is done on a database replica that lags behind
|
||||
even for fractions of a second, the decision to allow selling the ticket will be made
|
||||
on out-of-data data and you can end up with more tickets sold than configured. Similarly,
|
||||
you could imagine situations leading to double payments etc.
|
||||
|
||||
If you do have a replica, you *can* tell pretix about it :ref:`in your configuration <config-replica>`.
|
||||
This way, pretix can offload complex read-only queries to the replica when it is safe to do so.
|
||||
As of pretix 2.7, this is mainly used for search queries in the backend and for rendering the
|
||||
product list and event lists in the frontend, but we plan on expanding this in the future.
|
||||
|
||||
Therefore, for now our clear recommendation is: Try to scale your database vertically and put
|
||||
it on the most powerful machine you have available.
|
||||
|
||||
redis
|
||||
"""""
|
||||
|
||||
While redis is a very important part that glues together some of the components, it isn't used
|
||||
heavily and can usually handle a fairly large pretix installation easily on a single modern
|
||||
CPU core.
|
||||
Having some memory available is good in case of e.g. lots of tasks queuing up during a traffic peak, but we wouldn't expect ever needing more than a gigabyte of it.
|
||||
|
||||
Feel free to set up a redis cluster for availability – but you won't need it for performance in a long time.
|
||||
|
||||
The limitations
|
||||
---------------
|
||||
|
||||
Up to a certain point, pretix scales really well. However, there are a few things that we consider
|
||||
even more important than scalability, and those are correctness and reliability. We want you to be
|
||||
able to trust that pretix will not sell more tickets than you intended or run into similar error
|
||||
cases.
|
||||
|
||||
Combined with pretix' flexibility and complexity, especially around vouchers and quotas, this creates
|
||||
some hard issues. In many cases, we need to fall back to event-global locking for some actions which
|
||||
are likely to run with high concurrency and cause harm.
|
||||
|
||||
For every event, only one of these locking actions can be run at the same time. Examples for this are
|
||||
adding products limited by a quota to a cart, adding items to a cart using a voucher or placing an order
|
||||
consisting of cart positions that don't have a valid reservation for much longer. In these cases, it is
|
||||
currently not realistically possible to exceed selling **approx. 500 orders per minute per event**, even
|
||||
if you add more hardware.
|
||||
If you have an unlimited number of tickets, we can apply fewer locking and we've reached **approx.
|
||||
1500 orders per minute per event** in benchmarks, although even more should be possible.
|
||||
|
||||
We're working to reduce the number of cases in which this is relevant and thereby improve the possible
|
||||
throughput. If you want to use pretix for an event with 10,000+ tickets that are likely to be sold out
|
||||
within minutes, please get in touch to discuss possible solutions. We'll work something out for you!
|
||||
|
||||
|
||||
.. _object storage cluster: https://behind.pretix.eu/2018/03/20/high-available-cdn/
|
||||
@@ -181,4 +181,37 @@ as the string values ``true`` and ``false``.
|
||||
If the ``ordering`` parameter is documented for a resource, you can use it to sort the result set by one of the allowed
|
||||
fields. Prepend a ``-`` to the field name to reverse the sort order.
|
||||
|
||||
|
||||
Idempotency
|
||||
-----------
|
||||
|
||||
Our API supports an idempotency mechanism to make sure you can safely retry operations without accidentally performing
|
||||
them twice. This is useful if an API call experiences interruptions in transit, e.g. due to a network failure, and you
|
||||
do not know if it completed successfully.
|
||||
|
||||
To perform an idempotent request, add a ``X-Idempotency-Key`` header with a random string value (we recommend a version
|
||||
4 UUID) to your request. If we see a second request with the same ``X-Idempotency-Key`` and the same ``Authorization``
|
||||
and ``Cookie`` headers, we will not perform the action for a second time but return the exact same response instead.
|
||||
|
||||
Please note that this also goes for most error responses. For example, if we returned you a ``403 Permission Denied``
|
||||
error and you retry with the same ``X-Idempotency-Key``, you will get the same error again, even if you were granted
|
||||
permission in the meantime! This includes internal server errors on our side that might have been fixed in the meantime.
|
||||
|
||||
There are only three exceptions to the rule:
|
||||
|
||||
* Responses with status code ``409 Conflict`` are not cached. If you send the request again, it will be executed as a
|
||||
new request, since these responses are intended to be retried.
|
||||
|
||||
* Rate-limited responses with status code ``429 Too Many Requests`` are not cached and you can safely retry them.
|
||||
|
||||
* Responses with status code ``503 Service Unavailable`` are not cached and you can safely retry them.
|
||||
|
||||
If you send a request with an ``X-Idempotency-Key`` header that we have seen before but that has not yet received a
|
||||
response, you will receive a response with status code ``409 Conflict`` and are asked to retry after five seconds.
|
||||
|
||||
We store idempotency keys for 24 hours, so you should never retry a request after a longer time period.
|
||||
|
||||
All ``POST``, ``PUT``, ``PATCH``, or ``DELETE`` api calls support idempotency keys. Adding an idempotency key to a
|
||||
``GET``, ``HEAD``, or ``OPTIONS`` request has no effect.
|
||||
|
||||
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax
|
||||
|
||||
131
doc/api/resources/billing_invoices.rst
Normal file
131
doc/api/resources/billing_invoices.rst
Normal file
@@ -0,0 +1,131 @@
|
||||
pretix Hosted billing invoices
|
||||
==============================
|
||||
|
||||
This endpoint allows you to access invoices you received for pretix Hosted. It only contains invoices created starting
|
||||
November 2017.
|
||||
|
||||
.. note:: Only available on pretix Hosted, not on self-hosted pretix instances.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
invoice_number string Invoice number
|
||||
date_issued date Invoice date
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/billing_invoices/
|
||||
|
||||
Returns a list of all invoices to a given organizer.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/billing_invoices/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"invoice_number": "R2019002",
|
||||
"date_issued": "2019-06-03"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_issued`` and
|
||||
its reverse, ``-date_issued``. Default: ``date_issued``.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/billing_invoices/(invoice_number)/
|
||||
|
||||
Returns information on one invoice, identified by its invoice number.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/billing_invoices/R2019002/ 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
|
||||
|
||||
{
|
||||
"invoice_number": "R2019002",
|
||||
"date_issued": "2019-06-03"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param invoice_number: The ``invoice_number`` field of the invoice to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/billing_invoices/(invoice_number)/download/
|
||||
|
||||
Download an invoice in PDF format.
|
||||
|
||||
.. warning:: After we created the invoices, they are placed in review with our accounting department. You will
|
||||
already see them in the API at this point, but you are not able to download them until they completed
|
||||
review and are sent to you via email. This usually takes a few hours. If you try to download them
|
||||
in this time frame, you will receive a status code :http:statuscode:`423`.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/billing_invoices/R2019002/download/ 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/pdf
|
||||
|
||||
...
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param invoice_number: The ``invoice_number`` field of the invoice to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 423: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||
seconds.
|
||||
@@ -20,7 +20,7 @@ internal_name string An optional nam
|
||||
description multi-lingual string A public description (might include markdown, can
|
||||
be ``null``)
|
||||
position integer An integer, used for sorting the categories
|
||||
is_addon boolean If ``True``, items within this category are not on sale
|
||||
is_addon boolean If ``true``, items within this category are not on sale
|
||||
on their own but the category provides a source for
|
||||
defining add-ons for other products.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
@@ -156,14 +156,14 @@ Endpoints
|
||||
"checkin_count": 17,
|
||||
"position_count": 42,
|
||||
"event": {
|
||||
"name": "Demo Converence",
|
||||
"name": "Demo Conference"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"name": "T-Shirt",
|
||||
"id": 1,
|
||||
"checkin_count": 1,
|
||||
"admission": False,
|
||||
"admission": false,
|
||||
"position_count": 1,
|
||||
"variations": [
|
||||
{
|
||||
@@ -184,7 +184,7 @@ Endpoints
|
||||
"name": "Ticket",
|
||||
"id": 2,
|
||||
"checkin_count": 15,
|
||||
"admission": True,
|
||||
"admission": true,
|
||||
"position_count": 22,
|
||||
"variations": []
|
||||
}
|
||||
@@ -336,11 +336,24 @@ Order position endpoints
|
||||
|
||||
The order positions endpoint has been extended by the filter queries ``voucher`` and ``voucher__code``.
|
||||
|
||||
.. versionchanged:: 2.7
|
||||
|
||||
The resource now contains the new attributes ``require_attention`` and ``order__status`` and accepts the new
|
||||
``ignore_status`` filter. The ``attendee_name`` field is now "smart" (see below) and the redemption endpoint
|
||||
returns ``400`` instead of ``404`` on tickets which are known but not paid.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/
|
||||
|
||||
Returns a list of all order positions within a given event. The result is the same as
|
||||
the :ref:`order-position-resource`, with one important difference: the ``checkins`` value will only include
|
||||
check-ins for the selected list.
|
||||
the :ref:`order-position-resource`, with the following differences:
|
||||
|
||||
* The ``checkins`` value will only include check-ins for the selected list.
|
||||
|
||||
* An additional boolean property ``require_attention`` will inform you whether either the order or the item
|
||||
have the ``checkin_attention`` flag set.
|
||||
|
||||
* If ``attendee_name`` is empty, it will automatically fall back to values from a parent product or from invoice
|
||||
addresses.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -407,6 +420,8 @@ Order position endpoints
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string ignore_status: If set to ``true``, results will be returned regardless of the state of
|
||||
the order they belong to and you will need to do your own filtering by order status.
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``order__code``,
|
||||
``order__datetime``, ``positionid``, ``attendee_name``, ``last_checked_in`` and ``order__email``. Default:
|
||||
``attendee_name,positionid``
|
||||
@@ -442,8 +457,15 @@ Order position endpoints
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/
|
||||
|
||||
Returns information on one order position, identified by its internal ID.
|
||||
The result format is the same as the :ref:`order-position-resource`, with one important difference: the
|
||||
``checkins`` value will only include check-ins for the selected list.
|
||||
The result is the same as the :ref:`order-position-resource`, with the following differences:
|
||||
|
||||
* The ``checkins`` value will only include check-ins for the selected list.
|
||||
|
||||
* An additional boolean property ``require_attention`` will inform you whether either the order or the item
|
||||
have the ``checkin_attention`` flag set.
|
||||
|
||||
* If ``attendee_name`` is empty, it will automatically fall back to values from a parent product or from invoice
|
||||
addresses.
|
||||
|
||||
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
|
||||
|
||||
@@ -528,7 +550,8 @@ Order position endpoints
|
||||
:<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required
|
||||
questions that have not been filled. Defaults to ``false``.
|
||||
:<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
|
||||
Defaults to ``false``.
|
||||
Defaults to ``false`` and only works when ``include_pending`` is set on the check-in
|
||||
list.
|
||||
:<json string nonce: You can set this parameter to a unique random value to identify this check-in. If you're sending
|
||||
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
|
||||
|
||||
@@ -25,7 +25,7 @@ is_public boolean If ``true``, th
|
||||
presale_start datetime The date at which the ticket shop opens (or ``null``)
|
||||
presale_end datetime The date at which the ticket shop closes (or ``null``)
|
||||
location multi-lingual string The event location (or ``null``)
|
||||
has_subevents boolean ``True`` if the event series feature is active for this
|
||||
has_subevents boolean ``true`` if the event series feature is active for this
|
||||
event. Cannot change after event is created.
|
||||
meta_data dict Values set for organizer-specific meta data parameters.
|
||||
plugins list A list of package names of the enabled plugins for this
|
||||
@@ -50,6 +50,10 @@ plugins list A list of packa
|
||||
|
||||
The ``testmode`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.8
|
||||
|
||||
When cloning events, the ``testmode`` attribute will now be cloned, too.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -112,6 +116,9 @@ Endpoints
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned.
|
||||
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned.
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_from`` and
|
||||
``slug``. Keep in mind that ``date_from`` of event series does not really tell you anything.
|
||||
Default: ``slug``.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
@@ -246,7 +253,7 @@ Endpoints
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/clone/
|
||||
|
||||
Creates a new event with properties as set in the request body. The properties that are copied are: 'is_public',
|
||||
settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
|
||||
`testmode`, settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
|
||||
|
||||
If the 'plugins' and/or 'is_public' fields are present in the post body this will determine their value. Otherwise
|
||||
their value will be copied from the existing event.
|
||||
@@ -379,7 +386,7 @@ Endpoints
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to update
|
||||
:param event: The ``slug`` field of the event to update
|
||||
:statuscode 201: no error
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The event could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
@@ -11,6 +11,7 @@ Resources and endpoints
|
||||
categories
|
||||
items
|
||||
item_variations
|
||||
item_bundles
|
||||
item_add-ons
|
||||
questions
|
||||
question_options
|
||||
@@ -22,3 +23,4 @@ Resources and endpoints
|
||||
waitinglist
|
||||
carts
|
||||
webhooks
|
||||
billing_invoices
|
||||
|
||||
@@ -13,7 +13,7 @@ Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
number string Invoice number (with prefix)
|
||||
order string Order code of the order this invoice belongs to
|
||||
is_cancellation boolean ``True``, if this invoice is the cancellation of a
|
||||
is_cancellation boolean ``true``, if this invoice is the cancellation of a
|
||||
different invoice.
|
||||
invoice_from string Sender address
|
||||
invoice_to string Receiver address
|
||||
|
||||
@@ -189,7 +189,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"max_count": 10
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
242
doc/api/resources/item_bundles.rst
Normal file
242
doc/api/resources/item_bundles.rst
Normal file
@@ -0,0 +1,242 @@
|
||||
Item bundles
|
||||
============
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
With bundles, you can specify products that are included within other products. There are two premier use cases of this:
|
||||
|
||||
* Package discounts. For example, you could offer a discounted package that includes three tickets but can only be
|
||||
bought as a whole. With a bundle including three times the usual product, the package will automatically pull three
|
||||
sub-items into the cart, making sure of correct quota calculation and issuance of the correct number of tickets.
|
||||
|
||||
* Tax splitting. For example, if your conference ticket includes a part that is subject to different taxation and that
|
||||
you need to put on the invoice separately. When you putting a "designated price" on a bundled sub-item, pretix will
|
||||
use that price to show a split taxation.
|
||||
|
||||
The bundles resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the bundling configuration
|
||||
bundled_item integer Internal ID of the item that is included.
|
||||
bundled_variation integer Internal ID of the variation of the item (or ``null``).
|
||||
count integer Number of items included
|
||||
designated_price money (string) Designated price of the bundled product. This will be
|
||||
used to split the price of the base item e.g. for mixed
|
||||
taxation. This is not added to the price.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2.6
|
||||
|
||||
This resource has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/
|
||||
|
||||
Returns a list of all bundles for a given item.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/items/11/bundles/ 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": 2,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 3,
|
||||
"bundled_item": 3,
|
||||
"bundled_variation": null,
|
||||
"count": 1,
|
||||
"designated_price": "0.00"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"bundled_item": 3,
|
||||
"bundled_variation": null,
|
||||
"count": 2,
|
||||
"designated_price": "1.50"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param item: The ``id`` field of the item to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/(id)/
|
||||
|
||||
Returns information on one bundle configuration, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/items/1/bundles/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"bundled_item": 3,
|
||||
"bundled_variation": null,
|
||||
"count": 2,
|
||||
"designated_price": "1.50"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param item: The ``id`` field of the item to fetch
|
||||
:param id: The ``id`` field of the bundle to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:post:: /api/v1/organizers/bigevents/events/sampleconf/items/1/bundles/
|
||||
|
||||
Creates a new bundle configuration
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"bundled_item": 3,
|
||||
"bundled_variation": null,
|
||||
"count": 2,
|
||||
"designated_price": "1.50"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"bundled_item": 3,
|
||||
"bundled_variation": null,
|
||||
"count": 2,
|
||||
"designated_price": "1.50"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event/item to create a bundle-configuration for
|
||||
:param event: The ``slug`` field of the event to create a bundle configuration for
|
||||
:param item: The ``id`` field of the item to create a bundle configuration for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The bundle could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/(id)/
|
||||
|
||||
Update a bundle configuration. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all
|
||||
fields of the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields
|
||||
that you want to change.
|
||||
|
||||
You can change all fields of the resource except the ``id`` field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/bundles/3/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"count": 2
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"bundled_item": 3,
|
||||
"bundled_variation": null,
|
||||
"count": 2,
|
||||
"designated_price": "1.50"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param item: The ``id`` field of the item to modify
|
||||
:param id: The ``id`` field of the bundle to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The bundle configuration could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/bundles/(id)/
|
||||
|
||||
Delete a bundle configuration.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/bundles/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to modify
|
||||
:param id: The ``id`` field of the bundle to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
@@ -18,12 +18,18 @@ default_price money (string) The price set d
|
||||
price money (string) The price used for this variation. This is either the
|
||||
same as ``default_price`` if that value is set or equal
|
||||
to the item's ``default_price`` (read-only).
|
||||
active boolean If ``False``, this variation will not be sold or shown.
|
||||
original_price money (string) An original price, shown for comparison, not used
|
||||
for price calculations (or ``null``).
|
||||
active boolean If ``false``, this variation will not be sold or shown.
|
||||
description multi-lingual string A public description of the variation. May contain
|
||||
Markdown syntax or can be ``null``.
|
||||
position integer An integer, used for sorting
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2.7
|
||||
|
||||
The attribute ``original_price`` has been added.
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
This resource has been added.
|
||||
@@ -67,7 +73,8 @@ Endpoints
|
||||
},
|
||||
"position": 0,
|
||||
"default_price": "223.00",
|
||||
"price": 223.0
|
||||
"price": 223.0,
|
||||
"original_price": null,
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
@@ -120,6 +127,7 @@ Endpoints
|
||||
},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
@@ -167,6 +175,7 @@ Endpoints
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
@@ -216,6 +225,7 @@ Endpoints
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
|
||||
@@ -21,34 +21,35 @@ default_price money (string) The item price
|
||||
overwritten by variations or other options.
|
||||
category integer The ID of the category this item belongs to
|
||||
(or ``null``).
|
||||
active boolean If ``False``, the item is hidden from all public lists
|
||||
active boolean If ``false``, the item is hidden from all public lists
|
||||
and will not be sold.
|
||||
description multi-lingual string A public description of the item. May contain Markdown
|
||||
syntax or can be ``null``.
|
||||
free_price boolean If ``True``, customers can change the price at which
|
||||
free_price boolean If ``true``, customers can change the price at which
|
||||
they buy the product (however, the price can't be set
|
||||
lower than the price defined by ``default_price`` or
|
||||
otherwise).
|
||||
tax_rate decimal (string) The VAT rate to be applied for this item.
|
||||
tax_rate decimal (string) The VAT rate to be applied for this item (read-only,
|
||||
set through ``tax_rule``).
|
||||
tax_rule integer The internal ID of the applied tax rule (or ``null``).
|
||||
admission boolean ``True`` for items that grant admission to the event
|
||||
(such as primary tickets) and ``False`` for others
|
||||
admission boolean ``true`` for items that grant admission to the event
|
||||
(such as primary tickets) and ``false`` for others
|
||||
(such as add-ons or merchandise).
|
||||
position integer An integer, used for sorting
|
||||
picture string A product picture to be displayed in the shop
|
||||
(read-only).
|
||||
(read-only, can be ``null``).
|
||||
sales_channels list of strings Sales channels this product is available on, such as
|
||||
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
|
||||
available_from datetime The first date time at which this item can be bought
|
||||
(or ``null``).
|
||||
available_until datetime The last date time at which this item can be bought
|
||||
(or ``null``).
|
||||
require_voucher boolean If ``True``, this item can only be bought using a
|
||||
require_voucher boolean If ``true``, this item can only be bought using a
|
||||
voucher that is specifically assigned to this item.
|
||||
hide_without_voucher boolean If ``True``, this item is only shown during the voucher
|
||||
hide_without_voucher boolean If ``true``, this item is only shown during the voucher
|
||||
redemption process, but not in the normal shop
|
||||
frontend.
|
||||
allow_cancel boolean If ``False``, customers cannot cancel orders containing
|
||||
allow_cancel boolean If ``false``, customers cannot cancel orders containing
|
||||
this item.
|
||||
min_per_order integer This product can only be bought if it is included at
|
||||
least this many times in the order (or ``null`` for no
|
||||
@@ -56,16 +57,17 @@ min_per_order integer This product ca
|
||||
max_per_order integer This product can only be bought if it is included at
|
||||
most this many times in the order (or ``null`` for no
|
||||
limitation).
|
||||
checkin_attention boolean If ``True``, the check-in app should show a warning
|
||||
checkin_attention boolean If ``true``, the check-in app should show a warning
|
||||
that this ticket requires special attention if such
|
||||
a product is being scanned.
|
||||
original_price money (string) An original price, shown for comparison, not used
|
||||
for price calculations.
|
||||
require_approval boolean If ``True``, orders with this product will need to be
|
||||
for price calculations (or ``null``).
|
||||
require_approval boolean If ``true``, orders with this product will need to be
|
||||
approved by the event organizer before they can be
|
||||
paid.
|
||||
generate_tickets boolean If ``False``, tickets are never generated for this
|
||||
product, regardless of other settings. If ``True``,
|
||||
require_bundling boolean If ``true``, this item is only available as part of bundles.
|
||||
generate_tickets boolean If ``false``, tickets are never generated for this
|
||||
product, regardless of other settings. If ``true``,
|
||||
tickets are generated even if this is a
|
||||
non-admission or add-on product, regardless of event
|
||||
settings. If this is ``null``, regular ticketing
|
||||
@@ -80,7 +82,9 @@ variations list of objects A list with one
|
||||
├ price money (string) The price used for this variation. This is either the
|
||||
same as ``default_price`` if that value is set or equal
|
||||
to the item's ``default_price``.
|
||||
├ active boolean If ``False``, this variation will not be sold or shown.
|
||||
├ original_price money (string) An original price, shown for comparison, not used
|
||||
for price calculations (or ``null``).
|
||||
├ active boolean If ``false``, this variation will not be sold or shown.
|
||||
├ description multi-lingual string A public description of the variation. May contain
|
||||
Markdown syntax or can be ``null``.
|
||||
└ position integer An integer, used for sorting
|
||||
@@ -91,10 +95,23 @@ addons list of objects Definition of a
|
||||
chosen from.
|
||||
├ min_count integer The minimal number of add-ons that need to be chosen.
|
||||
├ max_count integer The maximal number of add-ons that can be chosen.
|
||||
└ position integer An integer, used for sorting
|
||||
├ position integer An integer, used for sorting
|
||||
└ price_included boolean Adding this add-on to the item is free
|
||||
bundles list of objects Definition of bundles that are included in this item.
|
||||
Only writable during creation,
|
||||
use separate endpoint to modify this later.
|
||||
├ bundled_item integer Internal ID of the item that is included.
|
||||
├ bundled_variation integer Internal ID of the variation of the item (or ``null``).
|
||||
├ count integer Number of items included
|
||||
└ designated_price money (string) Designated price of the bundled product. This will be
|
||||
used to split the price of the base item e.g. for mixed
|
||||
taxation. This is not added to the price.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2.7
|
||||
|
||||
The attribute ``original_price`` has been added for ``variations``.
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
|
||||
The attribute ``tax_rule`` has been added. ``tax_rate`` is kept for compatibility. The attribute
|
||||
@@ -121,15 +138,20 @@ addons list of objects Definition of a
|
||||
|
||||
The ``generate_tickets`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.6
|
||||
|
||||
The ``bundles`` and ``require_bundling`` attributes have been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
|
||||
Please note that an item either always has variations or never has. Once created with variations the item can never
|
||||
change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least
|
||||
one variation.
|
||||
|
||||
Also note that ``variations`` and ``addons`` are only supported on ``POST``. To update/delete variations and add-ons please
|
||||
use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT`` with nested
|
||||
``variations`` and/or ``addons``.
|
||||
Also note that ``variations``, ``bundles``, and ``addons`` are only supported on ``POST``. To update/delete variations,
|
||||
bundles, and add-ons please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT``
|
||||
with nested ``variations``, ``bundles`` and/or ``addons``.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -186,11 +208,13 @@ Endpoints
|
||||
"has_variations": false,
|
||||
"generate_tickets": null,
|
||||
"require_approval": false,
|
||||
"require_bundling": false,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
@@ -199,12 +223,14 @@ Endpoints
|
||||
"value": {"en": "Regular"},
|
||||
"default_price": null,
|
||||
"price": "23.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"addons": []
|
||||
"addons": [],
|
||||
"bundles": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -273,11 +299,13 @@ Endpoints
|
||||
"checkin_attention": false,
|
||||
"has_variations": false,
|
||||
"require_approval": false,
|
||||
"require_bundling": false,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
@@ -286,12 +314,14 @@ Endpoints
|
||||
"value": {"en": "Regular"},
|
||||
"default_price": null,
|
||||
"price": "23.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"addons": []
|
||||
"addons": [],
|
||||
"bundles": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -340,11 +370,13 @@ Endpoints
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"require_approval": false,
|
||||
"require_bundling": false,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
@@ -353,12 +385,14 @@ Endpoints
|
||||
"value": {"en": "Regular"},
|
||||
"default_price": null,
|
||||
"price": "23.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"addons": []
|
||||
"addons": [],
|
||||
"bundles": []
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -396,11 +430,13 @@ Endpoints
|
||||
"checkin_attention": false,
|
||||
"has_variations": true,
|
||||
"require_approval": false,
|
||||
"require_bundling": false,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
@@ -409,12 +445,14 @@ Endpoints
|
||||
"value": {"en": "Regular"},
|
||||
"default_price": null,
|
||||
"price": "23.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"addons": []
|
||||
"addons": [],
|
||||
"bundles": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create an item for
|
||||
@@ -483,11 +521,13 @@ Endpoints
|
||||
"checkin_attention": false,
|
||||
"has_variations": true,
|
||||
"require_approval": false,
|
||||
"require_bundling": false,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
@@ -496,12 +536,14 @@ Endpoints
|
||||
"value": {"en": "Regular"},
|
||||
"default_price": null,
|
||||
"price": "23.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"addons": []
|
||||
"addons": [],
|
||||
"bundles": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
|
||||
@@ -26,7 +26,7 @@ status string Order status, o
|
||||
* ``p`` – paid
|
||||
* ``e`` – expired
|
||||
* ``c`` – canceled
|
||||
testmode boolean If ``True``, this order was created when the event was in
|
||||
testmode boolean If ``true``, this order was created when the event was in
|
||||
test mode. Only orders in test mode can be deleted.
|
||||
secret string The secret contained in the link sent to the customer
|
||||
email string The customer email address
|
||||
@@ -39,13 +39,13 @@ payment_date date **DEPRECATED AN
|
||||
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order
|
||||
total money (string) Total value of this order
|
||||
comment string Internal comment on this order
|
||||
checkin_attention boolean If ``True``, the check-in app should show a warning
|
||||
checkin_attention boolean If ``true``, the check-in app should show a warning
|
||||
that this ticket requires special attention if a ticket
|
||||
of this order is scanned.
|
||||
invoice_address object Invoice address information (can be ``null``)
|
||||
├ last_modified datetime Last modification date of the address
|
||||
├ company string Customer company name
|
||||
├ is_business boolean Business or individual customers (always ``False``
|
||||
├ is_business boolean Business or individual customers (always ``false``
|
||||
for orders created before pretix 1.7, do not rely on
|
||||
it).
|
||||
├ name string Customer name
|
||||
@@ -56,7 +56,7 @@ invoice_address object Invoice address
|
||||
├ country string Customer country
|
||||
├ internal_reference string Customer's internal reference to be printed on the invoice
|
||||
├ 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.
|
||||
positions list of objects List of non-canceled order positions (see below)
|
||||
@@ -78,9 +78,9 @@ downloads list of objects List of ticket
|
||||
download options.
|
||||
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
|
||||
└ url string Download URL
|
||||
require_approval boolean If ``True`` and the order is pending, this order
|
||||
require_approval boolean If ``true`` and the order is pending, this order
|
||||
needs approval by an organizer before it can
|
||||
continue. If ``True`` and the order is canceled,
|
||||
continue. If ``true`` and the order is canceled,
|
||||
this order has been denied by the event organizer.
|
||||
payments list of objects List of payment processes (see below)
|
||||
refunds list of objects List of refund processes (see below)
|
||||
@@ -295,7 +295,7 @@ List of all orders
|
||||
"require_approval": false,
|
||||
"invoice_address": {
|
||||
"last_modified": "2017-12-01T10:00:00Z",
|
||||
"is_business": True,
|
||||
"is_business": true,
|
||||
"company": "Sample company",
|
||||
"name": "John Doe",
|
||||
"name_parts": {"full_name": "John Doe"},
|
||||
@@ -305,7 +305,7 @@ List of all orders
|
||||
"country": "Testikistan",
|
||||
"internal_reference": "",
|
||||
"vat_id": "EU123456789",
|
||||
"vat_id_validated": False
|
||||
"vat_id_validated": false
|
||||
},
|
||||
"positions": [
|
||||
{
|
||||
@@ -373,8 +373,8 @@ List of all orders
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``code`` and
|
||||
``status``. Default: ``datetime``
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``code``,
|
||||
``last_modified``, and ``status``. Default: ``datetime``
|
||||
:query string code: Only return orders that match the given order code
|
||||
:query string status: Only return orders in the given order status (see above)
|
||||
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
|
||||
@@ -385,6 +385,7 @@ List of all orders
|
||||
:query datetime modified_since: Only return orders that have changed since the given date. Be careful: We only
|
||||
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
|
||||
you will not notice it using this method.
|
||||
:query datetime created_since: Only return orders that have been created since the given date.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
|
||||
@@ -437,7 +438,7 @@ Fetching individual orders
|
||||
"invoice_address": {
|
||||
"last_modified": "2017-12-01T10:00:00Z",
|
||||
"company": "Sample company",
|
||||
"is_business": True,
|
||||
"is_business": true,
|
||||
"name": "John Doe",
|
||||
"name_parts": {"full_name": "John Doe"},
|
||||
"street": "Test street 12",
|
||||
@@ -446,7 +447,7 @@ Fetching individual orders
|
||||
"country": "Testikistan",
|
||||
"internal_reference": "",
|
||||
"vat_id": "EU123456789",
|
||||
"vat_id_validated": False
|
||||
"vat_id_validated": false
|
||||
},
|
||||
"positions": [
|
||||
{
|
||||
@@ -563,6 +564,92 @@ Order ticket download
|
||||
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||
seconds.
|
||||
|
||||
Updating order fields
|
||||
---------------------
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
|
||||
|
||||
Updates specific fields on an order. Currently, only the following fields are supported:
|
||||
|
||||
* ``email``
|
||||
|
||||
* ``checkin_attention``
|
||||
|
||||
* ``locale``
|
||||
|
||||
* ``comment``
|
||||
|
||||
* ``invoice_address`` (you always need to supply the full object, or ``null`` to delete the current address)
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "other@example.org",
|
||||
"locale": "de",
|
||||
"comment": "Foo",
|
||||
"checkin_attention": true
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
(Full order resource, see above.)
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event
|
||||
:param event: The ``slug`` field of the event
|
||||
:param code: The ``code`` field of the order to update
|
||||
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The order could not be updated due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
|
||||
|
||||
Generating new secrets
|
||||
----------------------
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/regenerate_secrets/
|
||||
|
||||
Triggers generation of new ``secret`` attributes for both the order and all order positions.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/regenerate_secrets/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
(Full order resource, see above.)
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event
|
||||
:param event: The ``slug`` field of the event
|
||||
:param code: The ``code`` field of the order to update
|
||||
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The order could not be updated due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
|
||||
|
||||
Deleting orders
|
||||
---------------
|
||||
|
||||
@@ -663,6 +750,7 @@ Creating orders
|
||||
should only use this if you know the specific payment provider in detail. Please keep in mind that the payment
|
||||
provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no*
|
||||
charge will be created), this is just informative in case you *handled the payment already*.
|
||||
* ``payment_date`` (optional) – Date and time of the completion of the payment.
|
||||
* ``comment`` (optional)
|
||||
* ``checkin_attention`` (optional)
|
||||
* ``invoice_address`` (optional)
|
||||
@@ -702,6 +790,8 @@ Creating orders
|
||||
* ``internal_type``
|
||||
* ``tax_rule``
|
||||
|
||||
* ``force`` (optional). If set to ``true``, quotas will be ignored.
|
||||
|
||||
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
|
||||
to incrementing integers starting with ``1``. Then, you can reference one of these
|
||||
IDs in the ``addon_to`` field of another position. Note that all add_ons for a specific position need to come
|
||||
@@ -714,7 +804,7 @@ Creating orders
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "dummy@example.org",
|
||||
@@ -731,7 +821,7 @@ Creating orders
|
||||
],
|
||||
"payment_provider": "banktransfer",
|
||||
"invoice_address": {
|
||||
"is_business": False,
|
||||
"is_business": false,
|
||||
"company": "Sample company",
|
||||
"name_parts": {"full_name": "John Doe"},
|
||||
"street": "Sesam Street 12",
|
||||
@@ -774,10 +864,10 @@ Creating orders
|
||||
|
||||
(Full order resource, see above.)
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create an item for
|
||||
:param event: The ``slug`` field of the event to create an item for
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create an order for
|
||||
:param event: The ``slug`` field of the event to create an order for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The item could not be created due to invalid submitted data or lack of quota.
|
||||
:statuscode 400: The order could not be created due to invalid submitted data or lack of quota.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
|
||||
order.
|
||||
@@ -902,7 +992,7 @@ Order state operations
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_expired/
|
||||
|
||||
Marks a unpaid order as expired.
|
||||
Marks an unpaid order as expired.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -1061,9 +1151,82 @@ Order state operations
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The order cannot be marked as denied since the current order status does not allow it.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this resource.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
|
||||
Generating invoices
|
||||
-------------------
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/create_invoice/
|
||||
|
||||
Creates an invoice for an order which currently does not have an invoice. Returns the
|
||||
invoice object.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/create_invoice/ 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
|
||||
|
||||
{
|
||||
"order": "FOO",
|
||||
"number": "DUMMY-00001",
|
||||
"is_cancellation": false,
|
||||
...
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param code: The ``code`` field of the order to create an invoice for
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The invoice can not be created (invoicing disabled, the order already has an invoice, …)
|
||||
: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 order does not exist.
|
||||
|
||||
Sending e-mails
|
||||
---------------
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/resend_link/
|
||||
|
||||
Sends an email to the buyer with the link to the order page.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/resend_link/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param code: The ``code`` field of the order to send an email for
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The order does not have an email address associated
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
:statuscode 503: The email could not be sent.
|
||||
|
||||
List of all order positions
|
||||
---------------------------
|
||||
|
||||
@@ -56,6 +56,8 @@ Endpoints
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``slug`` and
|
||||
``name``. Default: ``slug``.
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
|
||||
@@ -30,14 +30,17 @@ type string The expected ty
|
||||
* ``D`` – date
|
||||
* ``H`` – time
|
||||
* ``W`` – date and time
|
||||
required boolean If ``True``, the question needs to be filled out.
|
||||
* ``CC`` – country code (ISO 3666-1 alpha-2)
|
||||
required boolean If ``true``, the question needs to be filled out.
|
||||
position integer An integer, used for sorting
|
||||
items list of integers List of item IDs this question is assigned to.
|
||||
identifier string An arbitrary string that can be used for matching with
|
||||
other sources.
|
||||
ask_during_checkin boolean If ``True``, this question will not be asked while
|
||||
ask_during_checkin boolean If ``true``, this question will not be asked while
|
||||
buying the ticket, but will show up when redeeming
|
||||
the ticket instead.
|
||||
hidden boolean If ``true``, the question will only be shown in the
|
||||
backend.
|
||||
options list of objects In case of question type ``C`` or ``M``, this lists the
|
||||
available objects. Only writable during creation,
|
||||
use separate endpoint to modify this later.
|
||||
@@ -46,6 +49,16 @@ options list of objects In case of ques
|
||||
├ identifier string An arbitrary string that can be used for matching with
|
||||
other sources.
|
||||
└ answer multi-lingual string The displayed value of this option
|
||||
dependency_question integer Internal ID of a different question. The current
|
||||
question will only be shown if the question given in
|
||||
this attribute is set to the value given in
|
||||
``dependency_value``. This cannot be combined with
|
||||
``ask_during_checkin``.
|
||||
dependency_value string The value ``dependency_question`` needs to be set to.
|
||||
If ``dependency_question`` is set to a boolean
|
||||
question, this should be ``"true"`` or ``"false"``.
|
||||
Otherwise, it should be the ``identifier`` of a
|
||||
question option.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
@@ -58,6 +71,10 @@ options list of objects In case of ques
|
||||
Write methods have been added. The attribute ``identifier`` has been added to both the resource itself and the
|
||||
options resource. The ``position`` attribute has been added to the options resource.
|
||||
|
||||
.. versionchanged:: 2.7
|
||||
|
||||
The attribute ``hidden`` and the question type ``CC`` have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -100,6 +117,9 @@ Endpoints
|
||||
"position": 1,
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -165,6 +185,9 @@ Endpoints
|
||||
"position": 1,
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -214,6 +237,9 @@ Endpoints
|
||||
"items": [1, 2],
|
||||
"position": 1,
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"options": [
|
||||
{
|
||||
"answer": {"en": "S"}
|
||||
@@ -245,6 +271,9 @@ Endpoints
|
||||
"position": 1,
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -314,6 +343,9 @@ Endpoints
|
||||
"position": 2,
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
|
||||
@@ -20,6 +20,8 @@ name multi-lingual string The sub-event's
|
||||
event string The slug of the parent event
|
||||
active boolean If ``true``, the sub-event ticket shop is publicly
|
||||
available.
|
||||
is_public boolean If ``true``, the sub-event ticket shop is publicly
|
||||
shown in lists.
|
||||
date_from datetime The sub-event's start date
|
||||
date_to datetime The sub-event's end date (or ``null``)
|
||||
date_admission datetime The sub-event's admission date (or ``null``)
|
||||
@@ -45,6 +47,12 @@ meta_data dict Values set for
|
||||
|
||||
The ``event`` field has been added, together with filters on the list of dates and an organizer-level list.
|
||||
|
||||
.. versionchanged:: 2.6
|
||||
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
|
||||
|
||||
.. versionchanged:: 2.7
|
||||
|
||||
The attribute ``is_public`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -79,6 +87,7 @@ Endpoints
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"event": "sampleconf",
|
||||
"active": false,
|
||||
"is_public": true,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
@@ -103,11 +112,85 @@ Endpoints
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
|
||||
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param event: The ``slug`` field of the main 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:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/
|
||||
|
||||
Creates a new subevent.
|
||||
|
||||
Permission required: "Can create events"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/subevents/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"active": false,
|
||||
"is_public": true,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
"variation_price_overrides": [],
|
||||
"meta_data": {}
|
||||
}
|
||||
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"active": false,
|
||||
"is_public": true,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
"variation_price_overrides": [],
|
||||
"meta_data": {}
|
||||
}
|
||||
|
||||
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the main event
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The sub-event could not be created 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.
|
||||
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/
|
||||
|
||||
Returns information on one sub-event, identified by its ID.
|
||||
@@ -133,6 +216,7 @@ Endpoints
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"event": "sampleconf",
|
||||
"active": false,
|
||||
"is_public": true,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
@@ -149,13 +233,107 @@ Endpoints
|
||||
"meta_data": {}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``slug`` field of the sub-event to fetch
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the main event
|
||||
:param id: The ``id`` field of the sub-event 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:patch:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/
|
||||
|
||||
Updates a sub-event, identified by its ID. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to
|
||||
provide all fields of the resource, other fields will be reset to default. With ``PATCH``, you only need to provide
|
||||
the fields that you want to change.
|
||||
|
||||
Permission required: "Can change event settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"name": {"en": "New Subevent Name"},
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"price": "23.42"
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "New Subevent Name"},
|
||||
"event": "sampleconf",
|
||||
"active": false,
|
||||
"is_public": true,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"price": "23.42"
|
||||
}
|
||||
],
|
||||
"variation_price_overrides": [],
|
||||
"meta_data": {}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the main event
|
||||
:param id: The ``id`` field of the sub-event to update
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The sub-event could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/sub-event does not exist **or** you have no permission to update this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/
|
||||
|
||||
Delete a sub-event. Note that events with orders cannot be deleted to ensure data integrity.
|
||||
|
||||
Permission required: "Can change event settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the main event
|
||||
:param id: The ``id`` field of the sub-event to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/sub-event does not exist **or** you have no permission to delete this resource.
|
||||
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/subevents/
|
||||
|
||||
Returns a list of all sub-events of any event series you have access to within an organizer account.
|
||||
@@ -186,6 +364,7 @@ Endpoints
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"event": "sampleconf",
|
||||
"active": false,
|
||||
"is_public": true,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
|
||||
@@ -18,8 +18,8 @@ max_usages integer The maximum num
|
||||
redeemed integer The number of times this voucher already has been
|
||||
redeemed.
|
||||
valid_until datetime The voucher expiration date (or ``null``).
|
||||
block_quota boolean If ``True``, quota is blocked for this voucher.
|
||||
allow_ignore_quota boolean If ``True``, this voucher can be redeemed even if a
|
||||
block_quota boolean If ``true``, quota is blocked for this voucher.
|
||||
allow_ignore_quota boolean If ``true``, this voucher can be redeemed even if a
|
||||
product is sold out and even if quota is not blocked
|
||||
for this voucher.
|
||||
price_mode string Determines how this voucher affects product prices.
|
||||
|
||||
@@ -17,11 +17,11 @@ The webhook resource contains the following public fields:
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the webhook
|
||||
enabled boolean If ``False``, this webhook will not receive any notifications
|
||||
enabled boolean If ``false``, this webhook will not receive any notifications
|
||||
target_url string The URL to call
|
||||
all_events boolean If ``True``, this webhook will receive notifications
|
||||
all_events boolean If ``true``, this webhook will receive notifications
|
||||
on all events of this organizer
|
||||
limit_events list of strings If ``all_events`` is ``False``, this is a list of
|
||||
limit_events list of strings If ``all_events`` is ``false``, this is a list of
|
||||
event slugs this webhook is active for
|
||||
action_types list of strings A list of action type filters that limit the
|
||||
notifications sent to this webhook. See below for
|
||||
|
||||
@@ -66,7 +66,7 @@ source_suffix = '.rst'
|
||||
#source_encoding = 'utf-8-sig'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'contents'
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = 'pretix'
|
||||
@@ -234,7 +234,7 @@ latex_elements = {
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
('contents', 'pretix.tex', 'pretix Documentation',
|
||||
('index', 'pretix.tex', 'pretix Documentation',
|
||||
'Raphael Michel', 'manual'),
|
||||
]
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Core
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
|
||||
item_copy_data, register_sales_channels
|
||||
item_copy_data, register_sales_channels, register_global_settings
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
@@ -20,17 +20,13 @@ Order events
|
||||
There are multiple signals that will be sent out in the ordering cycle:
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: validate_cart, order_fee_calculation, order_paid, order_placed, order_fee_type_name, allow_ticket_download
|
||||
:members: validate_cart, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download
|
||||
|
||||
Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: order_info, order_meta_from_request
|
||||
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, checkout_flow_steps, order_info, order_meta_from_request, position_info
|
||||
|
||||
Request flow
|
||||
""""""""""""
|
||||
@@ -53,7 +49,7 @@ Backend
|
||||
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: logentry_display, logentry_object_link, requiredaction_display
|
||||
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events
|
||||
|
||||
Vouchers
|
||||
""""""""
|
||||
|
||||
@@ -49,15 +49,19 @@ description string A more verbose description of what your
|
||||
visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated.
|
||||
restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled
|
||||
for an event by system administrators / superusers.
|
||||
compatibility string Specifier for compatible pretix versions.
|
||||
================== ==================== ===========================================================
|
||||
|
||||
A working example would be::
|
||||
|
||||
from django.apps import AppConfig
|
||||
try:
|
||||
from pretix.base.plugins import PluginConfig
|
||||
except ImportError:
|
||||
raise RuntimeError("Please use pretix 2.7 or above to run this plugin!")
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class PaypalApp(AppConfig):
|
||||
class PaypalApp(PluginConfig):
|
||||
name = 'pretix_paypal'
|
||||
verbose_name = _("PayPal")
|
||||
|
||||
@@ -68,6 +72,7 @@ A working example would be::
|
||||
visible = True
|
||||
restricted = False
|
||||
description = _("This plugin allows you to receive payments via PayPal")
|
||||
compatibility = "pretix>=2.7.0"
|
||||
|
||||
|
||||
default_app_config = 'pretix_paypal.PaypalApp'
|
||||
|
||||
@@ -23,7 +23,7 @@ Organizers and events
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.Event
|
||||
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running, cache, lock, get_plugins, get_mail_backend, payment_term_last, get_payment_providers, get_invoice_renderers, active_subevents, invoice_renderer, settings
|
||||
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running, cache, lock, get_plugins, get_mail_backend, payment_term_last, get_payment_providers, get_invoice_renderers, invoice_renderer, settings
|
||||
|
||||
.. autoclass:: pretix.base.models.SubEvent
|
||||
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running
|
||||
|
||||
@@ -21,10 +21,12 @@ Your should install the following on your system:
|
||||
* Python 3.5 or newer
|
||||
* ``pip`` for Python 3 (Debian package: ``python3-pip``)
|
||||
* ``python-dev`` for Python 3 (Debian package: ``python3-dev``)
|
||||
* On Debian/Ubuntu: ``python-venv`` for Python 3 (Debian package: ``python3-venv``)
|
||||
* ``libffi`` (Debian package: ``libffi-dev``)
|
||||
* ``libssl`` (Debian package: ``libssl-dev``)
|
||||
* ``libxml2`` (Debian package ``libxml2-dev``)
|
||||
* ``libxslt`` (Debian package ``libxslt1-dev``)
|
||||
* ``libenchant1c2a`` (Debian package ``libenchant1c2a``)
|
||||
* ``msgfmt`` (Debian package ``gettext``)
|
||||
* ``git``
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ uses to communicate with the pretix server.
|
||||
"total": 42,
|
||||
"version": 3,
|
||||
"event": {
|
||||
"name": "Demo Converence",
|
||||
"name": "Demo Conference",
|
||||
"slug": "democon",
|
||||
"date_from": "2016-12-27T17:00:00Z",
|
||||
"date_to": "2016-12-30T18:00:00Z",
|
||||
|
||||
@@ -10,10 +10,12 @@ availabilities
|
||||
backend
|
||||
backends
|
||||
banktransfer
|
||||
Bcc
|
||||
boolean
|
||||
booleans
|
||||
cancelled
|
||||
casted
|
||||
Ceph
|
||||
checkbox
|
||||
checksum
|
||||
config
|
||||
@@ -52,6 +54,7 @@ linters
|
||||
memcached
|
||||
metadata
|
||||
middleware
|
||||
Minio
|
||||
mixin
|
||||
mixins
|
||||
multi
|
||||
@@ -93,7 +96,10 @@ renderer
|
||||
renderers
|
||||
reportlab
|
||||
SaaS
|
||||
scalability
|
||||
screenshot
|
||||
scss
|
||||
searchable
|
||||
selectable
|
||||
serializers
|
||||
serializers
|
||||
@@ -108,6 +114,7 @@ subdomains
|
||||
subevent
|
||||
subevents
|
||||
submodule
|
||||
subnet
|
||||
subpath
|
||||
Symfony
|
||||
systemd
|
||||
@@ -120,6 +127,7 @@ unconfigured
|
||||
unix
|
||||
unprefixed
|
||||
untrusted
|
||||
uptime
|
||||
username
|
||||
url
|
||||
versa
|
||||
|
||||
@@ -21,11 +21,18 @@ Frontpage text
|
||||
your product types, give more information on the event or for other general notices.
|
||||
You can use :ref:`Markdown syntax <markdown-guide>` in this field.
|
||||
|
||||
Voucher explanation
|
||||
This text will be shown above the voucher input box. You can use it to explain how to obtain a voucher and use it.
|
||||
|
||||
Show variations of a product expanded by default
|
||||
If this is not checked, a product with variations will be shown as one row in the show by default and will expand
|
||||
into multiple rows once it is clicked on. With this box checked, the variations will be shown as multiple rows
|
||||
right from the beginning.
|
||||
|
||||
Ask search engines not to index the ticket shop
|
||||
If this is checked, we will set a HTML meta attribute asking search engines by Google not to put this ticket shop
|
||||
into their searchable index.
|
||||
|
||||
|
||||
The lower part of the page contains settings that you can **either** set on organizer-level for all your events **or**
|
||||
override for this single event individually. Those are:
|
||||
@@ -35,6 +42,12 @@ Primary color
|
||||
customers. We suggest not choosing something to light, since text in that color should be readable on a white
|
||||
background and white text should be readable on a background of this color.
|
||||
|
||||
Accent color for success
|
||||
This color will be used for success messages. We suggest to choose a dark shade of green.
|
||||
|
||||
Accent color for errors
|
||||
This color will be used for error messages. We suggest to choose a dark shade of red.
|
||||
|
||||
Font
|
||||
Choose one of multiple fonts to use for your web shop.
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ event.
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
The page is separated into three parts: "E-mail settings", "E-mail content" and "SMTP settings". We will explain all
|
||||
of them in detail on this page.
|
||||
The page is separated into four parts: "E-mail settings", "E-mail design", "E-mail content" and "SMTP settings".
|
||||
We will explain all of them in detail on this page.
|
||||
|
||||
E-mail settings
|
||||
---------------
|
||||
@@ -30,10 +30,18 @@ Signature
|
||||
This text will be appended to all e-mails in form of a signature. This might be useful e.g. to add your contact
|
||||
details or any legal information that needs to be included with the e-mails.
|
||||
|
||||
Bcc address
|
||||
This email address will receive a copy of every event-related email.
|
||||
|
||||
E-mail design
|
||||
-------------
|
||||
|
||||
In this part, you can choose and preview the layout of your emails. More layouts can be added by pretix plugins.
|
||||
|
||||
E-mail content
|
||||
--------------
|
||||
|
||||
The middle part of the page allows you to customize the exact texts of all e-mails sent by the system automatically.
|
||||
The next part of the page allows you to customize the exact texts of all e-mails sent by the system automatically.
|
||||
You can click on the different boxes to expand them and see the texts.
|
||||
|
||||
Within the texts, you can use placeholders that will later by replaced by values depending on the event or order. Below
|
||||
@@ -45,6 +53,7 @@ Placeholder Description
|
||||
============================== ===============================================================================
|
||||
event The event name
|
||||
total The order's total value
|
||||
total_with_currency The order's total value with a localized currency sign
|
||||
currency The currency used for the event (three-letter code)
|
||||
payment_info Information text specific to the payment method (e.g. banking details)
|
||||
url An URL pointing to the download/status page of the order
|
||||
@@ -112,6 +121,22 @@ Reminder to download tickets
|
||||
attendees to download their tickets. The e-mail should include a link to the ticket download. This e-mail will only
|
||||
ever be sent if you specify a number of days.
|
||||
|
||||
Order approval process
|
||||
If you configure one of your products to "require approval", orders of that product will not immediately be confirmed
|
||||
but only after you approved them manually. In this case, the following e-mail templates will be sent out.
|
||||
|
||||
Received order
|
||||
After an order has been received, this e-mail will be sent automatically instead of the "order placed" e-mail from
|
||||
above.
|
||||
|
||||
Approved order
|
||||
This e-mail will be sent after you manually approved an order. This should include instructions to pay for the order,
|
||||
which is why this will only be used for a paid order. For a free order, the "free order" e-mail from above will
|
||||
be sent.
|
||||
|
||||
Denied order
|
||||
This e-mail will be sent out to customers when their order has been denied.
|
||||
|
||||
SMTP settings
|
||||
-------------
|
||||
|
||||
|
||||
@@ -11,18 +11,6 @@ The settings at "Settings" → "Invoice" allow you to specify if and how pretix
|
||||
|
||||
In particular, you can configure the following things:
|
||||
|
||||
Ask for invoice address
|
||||
If this checkbox is enabled, customers will be able to enter an invoice address during checkout. If you only enable
|
||||
this box, the invoice address will be optional to fill in.
|
||||
|
||||
Require invoice address
|
||||
If this checkbox is enabled, entering an invoice address will be obligatory for all customers and it will not be
|
||||
able to create an order without entering an address.
|
||||
|
||||
Require customer name
|
||||
If this checkbox is enabled, the street, city, and country fields of the invoice address will still be optional but
|
||||
the name field will be obligatory.
|
||||
|
||||
Generate invoices
|
||||
This field controls whether pretix should generate an invoice for an order. You have the following options:
|
||||
|
||||
@@ -51,6 +39,51 @@ Attach invoices to emails
|
||||
"Automatically for all created orders" or to the payment confirmation e-mails if it is set to "Automatically on
|
||||
payment".
|
||||
|
||||
Invoice number prefix
|
||||
This is the prefix that will be prepended to all your invoice numbers. For example, if you set this to "Inv", your
|
||||
invoices will be numbered Inv00001, Inv00002, etc. If you leave this field empty, your event slug will be used,
|
||||
followed by a dash, e.g. DEMOCON-00001.
|
||||
|
||||
Within one organizer account, events with the same number prefix will share their number range. For example, if you
|
||||
set this to "Inv" for all of your events, there will be only one invoice numbered Inv00007 across all your events
|
||||
and the numbers will have gaps within one event.
|
||||
|
||||
Generate invoices with consecutive numbers
|
||||
If enabled, invoices will be created with numerical invoice numbers in the order of their creation, i.e.
|
||||
PREFIX-00001, PREFIX-00002, and so on. If disabled, invoice numbers will instead be generated from the order code,
|
||||
i.e. PREFIX-YHASD-1. When in doubt, keep this option enabled since it might be legally required in your country,
|
||||
but disabling it has the advantage that your customers can not estimate the number of tickets sold by looking at
|
||||
the invoice numbers.
|
||||
|
||||
Invoice language
|
||||
This setting allows you to configure the language of all invoices. You can either set it to one of your event
|
||||
language or dynamically to the language used by the customer.
|
||||
|
||||
Show free products on invoices
|
||||
If enabled, products that do not cost anything will still show up on invoices. Note that the order needs to contain
|
||||
at least one non-free product in order to generate an invoice.
|
||||
|
||||
Show attendee names on invoices
|
||||
If enabled, the attendee name will be printed on the invoice for admission tickets.
|
||||
|
||||
Ask for invoice address
|
||||
If this checkbox is enabled, customers will be able to enter an invoice address during checkout. If you only enable
|
||||
this box, the invoice address will be optional to fill in.
|
||||
|
||||
Require invoice address
|
||||
If this checkbox is enabled, entering an invoice address will be obligatory for all customers and it will not be
|
||||
able to create an order without entering an address.
|
||||
|
||||
Require customer name
|
||||
If this checkbox is enabled, the street, city, and country fields of the invoice address will still be optional but
|
||||
the name field will be obligatory.
|
||||
|
||||
Require a business address
|
||||
If enabled, the invoice address form will require a company name and do not allow personal addresses.
|
||||
|
||||
Ask for beneficiary
|
||||
If enabled, the invoice address form will contain an additional field to input the beneficiary of the transaction.
|
||||
|
||||
Ask for VAT ID
|
||||
If enabled, the invoice address form will not only ask for a postal address, but also for a VAT ID. The VAT ID will
|
||||
always be an optional field.
|
||||
@@ -62,26 +95,13 @@ Generate invoices with consecutive numbers
|
||||
but disabling it has the advantage that your customers can not estimate the number of tickets sold by looking at
|
||||
the invoice numbers.
|
||||
|
||||
Invoice number prefix
|
||||
This is the prefix that will be prepended to all your invoice numbers. For example, if you set this to "Inv", your
|
||||
invoices will be numbered Inv00001, Inv00002, etc. If you leave this field empty, your event slug will be used,
|
||||
followed by a dash, e.g. DEMOCON-00001.
|
||||
|
||||
Within one organizer account, events with the same number prefix will share their number range. For example, if you
|
||||
set this to "Inv" for all of your events, there will be only one invoice numbered Inv00007 across all your events
|
||||
and the numbers will have gaps within one event.
|
||||
|
||||
Show free products on invoices
|
||||
If enabled, products that do not cost anything will still show up on invoices. Note that the order needs to contain
|
||||
at least one non-free product in order to generate an invoice.
|
||||
|
||||
Show attendee names on invoices
|
||||
If enabled, the attendee name will be printed on the invoice for admission tickets.
|
||||
|
||||
Your address
|
||||
This should be set to the address of the entity issuing the invoice (read: you) and will be printed inside
|
||||
Your invoice details
|
||||
These fields should be set to the address of the entity issuing the invoice (read: you) and will be printed inside
|
||||
the header of the invoice.
|
||||
|
||||
Invoice style
|
||||
This setting allows you to choose the design of your invoice. Additional designs can be added by pretix plugins.
|
||||
|
||||
Introductory text
|
||||
A free custom text that will be printed above the list of products on the invoice.
|
||||
|
||||
|
||||
260
doc/user/events/structureguide.rst
Normal file
260
doc/user/events/structureguide.rst
Normal file
@@ -0,0 +1,260 @@
|
||||
Product structure guide
|
||||
=======================
|
||||
|
||||
Between products, categories, variations, add-ons, bundles, and quotas, pretix provides a wide range of features that allow you to set up your event in the way you want it.
|
||||
However, it is easy to get lost in the process or to get started with building your event right.
|
||||
Often times, there are multiple ways to do something that come with different advantages and disadvantages.
|
||||
This guide will walk you through a number of typical examples of pretix event structures and will explain how to set them up – feel free to just skip ahead to a section relevant for you.
|
||||
|
||||
Terminology
|
||||
-----------
|
||||
|
||||
Products
|
||||
A product is a basic entity that can be bought. You can think of it as a ticket type, but it can be more things than just a ticket, it can also be a piece of merchandise, a parking slot, etc.
|
||||
You might find some places where they are called "items" instead, but we're trying to get rid of that.
|
||||
|
||||
Product categories
|
||||
Products can be sorted into categories. Each product can only be in one category. Category are mostly used for grouping related products together to make your event page easier to read for buyers. However, we'll need categories as well to set up some of the structures outlined below.
|
||||
|
||||
Product variations
|
||||
During creation of a product, you can decide that your product should have multiple variations. Variations of a product can differ in price, description, and availability, but are otherwise the same. You could use this e.g. for differentiating between a regular ticket and a discounted ticket for students, or when selling merchandise to differentiate the different sizes of a t-shirt.
|
||||
|
||||
Product add-ons
|
||||
Add-ons are products that are sold together with another product (which we will call the base product in this case). For example, you could have a base product "Conference ticket" and then define multiple workshops that can be chosen as an add-on.
|
||||
|
||||
Product bundles
|
||||
Bundles work very similarly to add-ons, but are different in the way that they are always automatically included with the base product and cannot be optional. In contrast to add-on products, the same product can be included multiple times in a bundle.
|
||||
|
||||
Quotas
|
||||
Quotas define the availability of products. A quota has a size (i.e. the number of products in the inventory) and is mapped to one or multiple products or variations.
|
||||
|
||||
Questions
|
||||
Questions are user-defined form fields that buyers will need to fill out when purchasing a product.
|
||||
|
||||
Use case: Multiple price levels
|
||||
-------------------------------
|
||||
|
||||
Imagine you're running a concert with general admission that sells a total of 200 tickets for two prices:
|
||||
|
||||
* Regular: € 25.00
|
||||
* Students: € 19.00
|
||||
|
||||
You can either set up two different products called e.g. "Regular ticket" and "Student ticket" with the respective prices, or two variations within the same product. In this simple case, it really doesn't matter.
|
||||
|
||||
In addition, you will need quotas. If you do not care how many of your tickets are sold to students, you should set up just **one quota** of 200 called e.g. "General admission" that you link to **both products**.
|
||||
|
||||
If you want to limit the number of student tickets to 50 to ensure a certain minimum revenue, but do not want to limit the number of regular tickets artificially, we suggest you to create the same quota of 200 that is linked to both products, and then create a **second quota** of 50 that is only linked to the student ticket. This way, the system will reduce both quotas whenever a student ticket is sold and only the larger quota when a regular ticket is sold.
|
||||
|
||||
Use case: Early-bird tiers
|
||||
--------------------------
|
||||
|
||||
Let's say you run a conference that has the following pricing scheme:
|
||||
|
||||
* 12 to 6 months before the event: € 450
|
||||
* 6 to 3 months before the event: € 550
|
||||
* closer than 3 months to the event: € 650
|
||||
|
||||
Of course, you could just set up one product and change its price at the given dates manually, but if you want to set this up automatically, here's how:
|
||||
|
||||
Create three products (e.g. "super early bird", "early bird", "regular ticket") with the respective prices and one shared quota of your total event capacity. Then, set the **available from** and **available until** configuration fields of the products to automatically turn them on and off based on the current date.
|
||||
|
||||
.. note::
|
||||
|
||||
pretix currently can't do early-bird tiers based on **ticket number** instead of time. We're planning this feature for later in 2019. For now, you'll need to monitor that manually.
|
||||
|
||||
Use case: Up-selling of ticket extras
|
||||
-------------------------------------
|
||||
|
||||
Let's assume you're putting up a great music festival, and to save trouble with handling payments on-site, you want to sell parking spaces together with your ticket. By using our add-on feature, you can prompt all users to book the parking space (to make sure they see it) and ensure that only people with a ticket can book a parking space. You can set it up like this:
|
||||
|
||||
* Create a base product "Festival admission"
|
||||
* Create a quota for the base product
|
||||
* Create a category "Ticket extras" and check "Products in this category are add-on products"
|
||||
* Create a product "Parking space" within that category
|
||||
* Create a quota for the parking space product
|
||||
* Go to the base product and select the tab "Add-Ons" at the top. Click "Add a new add-on" and choose the "Ticket extras" category. You can keep the numbers at 0 and 1.
|
||||
|
||||
During checkout, all buyers of the base product will now be prompted if they want to add the parking space.
|
||||
|
||||
.. tip::
|
||||
|
||||
You can also use add-on products for free things, just to keep tabs on capacity.
|
||||
|
||||
Use case: Conference with workshops
|
||||
-----------------------------------
|
||||
|
||||
When running a conference, you might also organize a number of workshops with smaller capacity. To be able to plan, it would be great to know which workshops an attendee plans to attend.
|
||||
|
||||
Your first and simplest option is to just create a multiple-choice question. This has the upside of making it easy for users to change their mind later on, but will not allow you to restrict the number of attendees signing up for a given workshop – or even charge extra for a given workshop.
|
||||
|
||||
The usually better option is to go with add-on products. Let's take for example the following conference schedule, in which the lecture can be attended by anyone, but the workshops only have space for 20 persons each:
|
||||
|
||||
==================== =================================== ===================================
|
||||
Time Room A Room B
|
||||
==================== =================================== ===================================
|
||||
Wednesday morning Lecture
|
||||
Wednesday afternoon Workshop A Workshop B
|
||||
Thursday morning Workshop C Workshop D (20 € extra charge)
|
||||
==================== =================================== ===================================
|
||||
|
||||
Assuming you already created one or more products for your general conference admission, we suggest that you additionally create:
|
||||
|
||||
* A category called "Workshops" with the checkbox "Products in this category are add-on products" activated
|
||||
|
||||
* A free product called "Wednesday afternoon" within the category "Workshops" and with two variations:
|
||||
|
||||
* Workshop A
|
||||
|
||||
* Workshop B
|
||||
|
||||
* A free product called "Thursday morning" within the category "Workshops" and with two variations:
|
||||
|
||||
* Workshop C
|
||||
|
||||
* Workshop D with a price of 20 €
|
||||
|
||||
* Four quotas for each of the workshops
|
||||
|
||||
* One add-on configuration on your base product that allows users to choose between 0 and 2 products from the category "Workshops"
|
||||
|
||||
Use case: Discounted packages
|
||||
-----------------------------
|
||||
|
||||
Imagine you run a trade show that opens on three consecutive days and you want to have the following pricing:
|
||||
|
||||
* Single day: € 10
|
||||
* Any two days: € 17
|
||||
* All three days: € 25
|
||||
|
||||
In this case, there are multiple different ways you could set this up with pretix.
|
||||
|
||||
Option A: Combination products
|
||||
""""""""""""""""""""""""""""""
|
||||
|
||||
With this option, you just set up all the different combinations someone could by as a separate product. In this case, you would need 7 products:
|
||||
|
||||
* Day 1 pass
|
||||
* Day 2 pass
|
||||
* Day 3 pass
|
||||
* Day 1+2 pass
|
||||
* Day 2+3 pass
|
||||
* Day 1+3 pass
|
||||
* All-day pass
|
||||
|
||||
Then, you create three quotas, each one with the maximum capacity of your venue on any given day:
|
||||
|
||||
* Day 1 quota, linked to "Day 1 pass", "Day 1+2 pass", "Day 1+3 pass", and "All-day pass"
|
||||
* Day 2 quota, linked to "Day 2 pass", "Day 1+2 pass", "Day 2+3 pass", and "All-day pass"
|
||||
* Day 3 quota, linked to "Day 3 pass", "Day 2+3 pass", "Day 1+3 pass", and "All-day pass"
|
||||
|
||||
This way, every person gets exactly one ticket that they can use for all days that they attend. You can later set up check-in lists appropriately to make sure only tickets valid for a certain day can be scanned on that day.
|
||||
|
||||
The benefit of this option is that your product structure and order structure stays very simple. However, the two-day packages scale badly when you need many products.
|
||||
|
||||
We recommend this setup for most setups in which the number of possible combinations does not exceed the number of parts (here: number of days) by much.
|
||||
|
||||
Option B: Add-ons and bundles
|
||||
"""""""""""""""""""""""""""""
|
||||
|
||||
We can combine the two features "product add-ons" and "product bundles" to set this up in a different way. Here, you would create the following five products:
|
||||
|
||||
* Day 1 pass in a category called "Day passes"
|
||||
* Day 2 pass in a category called "Day passes"
|
||||
* Day 3 pass in a category called "Day passes"
|
||||
* Two-day pass
|
||||
* All-day pass
|
||||
|
||||
This time, you will need five quotas:
|
||||
|
||||
* Day 1 quota, linked to "Day 1 pass"
|
||||
* Day 2 quota, linked to "Day 2 pass"
|
||||
* Day 3 quota, linked to "Day 3 pass"
|
||||
* Two-day pass quota, linked to "Two-day pass" (can be unlimited)
|
||||
* All-day pass quota, linked to "All-day pass" (can be unlimited)
|
||||
|
||||
Then, you open the "Add-On" tab in the settings of the **Two-day pass** product and create a new add-on configuration specifying the following options:
|
||||
|
||||
* Category: "Day passes"
|
||||
* Minimum number: 2
|
||||
* Maximum number: 2
|
||||
* Add-Ons are included in the price: Yes
|
||||
|
||||
This way, when buying a two-day pass, the user will be able to select *exactly* two days for free, which will then be added to the cart. Depending on your specific configuration, the user will now receive *two separate* tickets, one for each day.
|
||||
|
||||
For the all-day pass, you open the "Bundled products" tab in the settings of the **All-day pass** product and add **three** new bundled items with the following options:
|
||||
|
||||
* Bundled product: "Day 1/2/3"
|
||||
* Bundled variation: None
|
||||
* Count: 1
|
||||
* Designated price: 0
|
||||
|
||||
This way, when buying an all-day pass, three free day passes will *automatically* be added to the cart. Depending on your specific configuration, the user will now receive *three separate* tickets, one for each day.
|
||||
|
||||
This approach makes your order data more complicated, since e.g. someone who buys an all-day pass now technically bought **four products**. However, this option allows for more flexibility when you have lots of options to choose from.
|
||||
|
||||
.. tip::
|
||||
|
||||
Depending on the packages you offer, you **might not need both the add-on and the bundle feature**, i.e. you only need the add-on feature for the two-day pass and only the bundle feature for the all-day pass. You could also set up the two-day pass like we showed here, but the all-day pass like in option A!
|
||||
|
||||
Use case: Group discounts
|
||||
-------------------------
|
||||
|
||||
Often times, you want to give discounts for whole groups attending your event. pretix can't automatically discount based on volume, but there's still some ways you can set up group tickets.
|
||||
|
||||
Flexible group sizes
|
||||
""""""""""""""""""""
|
||||
|
||||
If you want to give out discounted tickets to groups starting at a given size, but still billed per person, you can do so by creating a special **Group ticket** at the per-person price and set the **Minimum amount per order** option of the ticket to the minimal group size.
|
||||
|
||||
This way, your ticket can be bought an arbitrary number of times – but no less than the given minimal amount per order.
|
||||
|
||||
Fixed group sizes
|
||||
"""""""""""""""""
|
||||
|
||||
If you want to sell group tickets in fixed sizes, e.g. a table of eight at your gala dinner, you can use product bundles. Assuming you already set up a ticket for admission of single persons, you then set up a second product **Table (8 persons)** with a discounted full price. Then, head to the **Bundled products** tab of that product and add one bundle configuration to include the single admission product **eight times**. Next, create an unlimited quota mapped to the new product.
|
||||
|
||||
This way, the purchase of a table will automatically create eight tickets, leading to a correct calculation of your total quota and, as expected, eight persons on your check-in list. You can even ask for the individual names of the persons during checkout.
|
||||
|
||||
Use case: Restricted audience
|
||||
-----------------------------
|
||||
|
||||
Not all events are for everyone. Sometimes, there is a good reason to restrict access to your event or parts of your event only to a specific, invited group. There's two ways to implement this with pretix:
|
||||
|
||||
Option A: Required voucher codes
|
||||
""""""""""""""""""""""""""""""""
|
||||
|
||||
If you check the option "**This product can only be bought using a voucher**" of one or multiple products, only people holding an applicable voucher code will be able to buy the product.
|
||||
|
||||
You can then generate voucher codes for the respective product and send them out to the group of possible attendees. If the recipients should still be able to choose between different products, you can create an additional quota and map the voucher to that quota instead of the products themselves.
|
||||
|
||||
There's also the second option "**This product will only be shown if a voucher matching the product is redeemed**". In this case, the existence of the product won't even be shown before a voucher code is entered – useful for a VIP option in a shop where you also sell other products to the general public. Please note that this option does **not** work with vouchers assigned to a quota, only with vouchers assigned directly to the product.
|
||||
|
||||
This option is appropriate if you know the group of people beforehand, e.g. members of a club, and you can mail them their access codes.
|
||||
|
||||
Option B: Order approvals
|
||||
"""""""""""""""""""""""""
|
||||
|
||||
If you do not know your audience already, but still want to restrict it to a certain group, e.g. people with a given profession, you can check the "**Buying this product requires approval**" in the settings of your product. If a customer tries to buy such a product, they will be able to place their order but can not proceed to payment. Instead, you will be asked to approve or deny the order and only if you approve it, we will send a payment link to the customer.
|
||||
|
||||
This requires the customer to interact with the ticket shop twice (once for the order, once for the payment) which adds a little more friction, but gives you full control over who attends the event.
|
||||
|
||||
Use case: Mixed taxation
|
||||
------------------------
|
||||
|
||||
Let's say you are a charitable organization in Germany and are allowed to charge a reduced tax rate of 7% for your educational event. However, your event includes a significant amount of food, you might need to charge a 19% tax rate on that portion. For example, your desired tax structure might then look like this:
|
||||
|
||||
* Conference ticket price: € 450 (incl. € 150 for food)
|
||||
|
||||
* incl. € 19.63 VAT at 7%
|
||||
* incl. € 23.95 VAT at 19%
|
||||
|
||||
You can implement this in pretix using product bundles. In order to do so, you should create the following two products:
|
||||
|
||||
* Conference ticket at € 450 with a 7% tax rule
|
||||
* Conference food at € 150 with a 19% tax rule and the option "**Only sell this product as part of a bundle**" set
|
||||
|
||||
In addition to your normal conference quota, you need to create an unlimited quota for the food product.
|
||||
|
||||
Then, head to the **Bundled products** tab of the "conference ticket" and add the "conference food" as a bundled product with a **designated price** of € 150.
|
||||
|
||||
Once a customer tries to buy the € 450 conference ticket, a sub-product will be added and the price will automatically be split into the two components, leading to a correct computation of taxes.
|
||||
@@ -25,6 +25,10 @@ Generate tickets for non-admission products
|
||||
By default, tickets will only be generated for products that are marked as admission products. Enable this option to
|
||||
generate tickets for all products instead.
|
||||
|
||||
Offer to download tickets even before an order is paid
|
||||
By default, ticket download is only possible for paid orders. If you run an event where people usually pay only after
|
||||
the event, you can check this box to enable ticket download even before.
|
||||
|
||||
Below these settings, the detail settings for the various ticket file formats are offered. They differ from format to
|
||||
format and only share the common "Enable" setting that can be used to turn them on. By default, pretix ships with
|
||||
a PDF output plugin that you can configure through a visual design editor.
|
||||
@@ -114,6 +114,35 @@ If you want to disable voucher input in the widget, you can pass the ``disable-v
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" disable-vouchers></pretix-widget>
|
||||
|
||||
Multi-event selection
|
||||
---------------------
|
||||
|
||||
If you want to embed multiple events in a single widget, you can do so. If it's multiple dates of an event series, just leave off the ``series`` attribute::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/series/"></pretix-widget>
|
||||
|
||||
If you want to include all your public events, you can just reference your organizer::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/"></pretix-widget>
|
||||
|
||||
There is an optional ``style`` parameter that let's you choose between a calendar view and a list view. If you do not set it, the choice will be taken from your organizer settings::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/series/" style="list"></pretix-widget>
|
||||
<pretix-widget event="https://pretix.eu/demo/series/" style="calendar"></pretix-widget>
|
||||
|
||||
You can see an example here:
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/series/" style="calendar"></pretix-widget>
|
||||
<noscript>
|
||||
<div class="pretix-widget">
|
||||
<div class="pretix-widget-info-message">
|
||||
JavaScript is disabled in your browser. To access our ticket shop without javascript, please <a target="_blank" href="https://pretix.eu/demo/series/">click here</a>.
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
pretix Button
|
||||
-------------
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ wanting to use pretix to sell tickets.
|
||||
organizers/index
|
||||
events/create
|
||||
events/settings
|
||||
events/structureguide
|
||||
events/widget
|
||||
faq
|
||||
markdown
|
||||
@@ -3,6 +3,13 @@
|
||||
PayPal
|
||||
======
|
||||
|
||||
.. note::
|
||||
|
||||
If you use pretix Hosted, you do not longer need to go through this tedious process! You can
|
||||
just open the PayPal settings in the payment section of your event, click "Connect to PayPal"
|
||||
and log in to your PayPal account. The following guide is only required for self-hosted
|
||||
versions of pretix.
|
||||
|
||||
To integrate PayPal with pretix, you first need to have an active PayPal merchant account. If you do not already have a
|
||||
PayPal account, you can create one on `paypal.com`_.
|
||||
If you look into pretix' settings, you are required to fill in two keys:
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.5.0"
|
||||
__version__ = "2.8.0"
|
||||
|
||||
@@ -19,7 +19,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
|
||||
if not device.initialized:
|
||||
raise exceptions.AuthenticationFailed('Device has not been initialized.')
|
||||
|
||||
if not device.api_token:
|
||||
if device.revoked:
|
||||
raise exceptions.AuthenticationFailed('Device access has been revoked.')
|
||||
|
||||
return AnonymousUser(), device
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.base.models import Device, Event
|
||||
from pretix.base.models import Device, Event, User
|
||||
from pretix.base.models.auth import SuperuserPermissionSet
|
||||
from pretix.base.models.organizer import Organizer, TeamAPIToken
|
||||
from pretix.helpers.security import (
|
||||
SessionInvalid, SessionReauthRequired, assert_session_valid,
|
||||
@@ -37,10 +38,13 @@ class EventPermission(BasePermission):
|
||||
slug=request.resolver_match.kwargs['event'],
|
||||
organizer__slug=request.resolver_match.kwargs['organizer'],
|
||||
).select_related('organizer').first()
|
||||
if not request.event or not perm_holder.has_event_permission(request.event.organizer, request.event):
|
||||
if not request.event or not perm_holder.has_event_permission(request.event.organizer, request.event, request=request):
|
||||
return False
|
||||
request.organizer = request.event.organizer
|
||||
request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
|
||||
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
|
||||
request.eventpermset = SuperuserPermissionSet()
|
||||
else:
|
||||
request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
|
||||
|
||||
if required_permission and required_permission not in request.eventpermset:
|
||||
return False
|
||||
@@ -49,9 +53,12 @@ class EventPermission(BasePermission):
|
||||
request.organizer = Organizer.objects.filter(
|
||||
slug=request.resolver_match.kwargs['organizer'],
|
||||
).first()
|
||||
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer):
|
||||
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer, request=request):
|
||||
return False
|
||||
request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
|
||||
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
|
||||
request.orgapermset = SuperuserPermissionSet()
|
||||
else:
|
||||
request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
|
||||
|
||||
if required_permission and required_permission not in request.orgapermset:
|
||||
return False
|
||||
|
||||
91
src/pretix/api/middleware.py
Normal file
91
src/pretix/api/middleware.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import json
|
||||
from hashlib import sha1
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.utils.timezone import now
|
||||
from rest_framework import status
|
||||
|
||||
from pretix.api.models import ApiCall
|
||||
|
||||
|
||||
class IdempotencyMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest):
|
||||
if request.method in ('GET', 'HEAD', 'OPTIONS'):
|
||||
return self.get_response(request)
|
||||
|
||||
if not request.path.startswith('/api/'):
|
||||
return self.get_response(request)
|
||||
|
||||
if not request.headers.get('X-Idempotency-Key'):
|
||||
return self.get_response(request)
|
||||
|
||||
auth_hash_parts = '{}:{}'.format(
|
||||
request.headers.get('Authorization', ''),
|
||||
request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')
|
||||
)
|
||||
auth_hash = sha1(auth_hash_parts.encode()).hexdigest()
|
||||
idempotency_key = request.headers.get('X-Idempotency-Key', '')
|
||||
|
||||
with transaction.atomic():
|
||||
call, created = ApiCall.objects.select_for_update().get_or_create(
|
||||
auth_hash=auth_hash,
|
||||
idempotency_key=idempotency_key,
|
||||
defaults={
|
||||
'locked': now(),
|
||||
'request_method': request.method,
|
||||
'request_path': request.path,
|
||||
'response_code': 0,
|
||||
'response_headers': '{}',
|
||||
'response_body': b''
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
resp = self.get_response(request)
|
||||
with transaction.atomic():
|
||||
if resp.status_code in (409, 429, 503):
|
||||
# This is the exception: These calls are *meant* to be retried!
|
||||
call.delete()
|
||||
else:
|
||||
call.response_code = resp.status_code
|
||||
if isinstance(resp.content, str):
|
||||
call.response_body = resp.content.encode()
|
||||
elif isinstance(resp.content, memoryview):
|
||||
call.response_body = resp.content.tobytes()
|
||||
elif isinstance(resp.content, bytes):
|
||||
call.response_body = resp.content
|
||||
elif hasattr(resp.content, 'read'):
|
||||
call.response_body = resp.read()
|
||||
elif hasattr(resp, 'data'):
|
||||
call.response_body = json.dumps(resp.data)
|
||||
else:
|
||||
call.response_body = repr(resp).encode()
|
||||
call.response_headers = json.dumps(resp._headers)
|
||||
call.locked = None
|
||||
call.save(update_fields=['locked', 'response_code', 'response_headers',
|
||||
'response_body'])
|
||||
return resp
|
||||
else:
|
||||
if call.locked:
|
||||
r = JsonResponse(
|
||||
{'detail': 'Concurrent request with idempotency key.'},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
r['Retry-After'] = 5
|
||||
return r
|
||||
|
||||
content = call.response_body
|
||||
if isinstance(content, memoryview):
|
||||
content = content.tobytes()
|
||||
r = HttpResponse(
|
||||
content=content,
|
||||
status=call.response_code,
|
||||
)
|
||||
for k, v in json.loads(call.response_headers).values():
|
||||
r[k] = v
|
||||
return r
|
||||
44
src/pretix/api/migrations/0004_auto_20190405_1048.py
Normal file
44
src/pretix/api/migrations/0004_auto_20190405_1048.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 2.1.5 on 2019-04-05 10:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('pretixbase', '0116_auto_20190402_0722'),
|
||||
('pretixapi', '0003_webhook_webhookcall_webhookeventlistener'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ApiCall',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('idempotency_key', models.CharField(db_index=True, max_length=190)),
|
||||
('auth_hash', models.CharField(db_index=True, max_length=190)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('locked', models.DateTimeField(null=True)),
|
||||
('request_method', models.CharField(max_length=20)),
|
||||
('request_path', models.CharField(max_length=255)),
|
||||
('response_code', models.PositiveIntegerField()),
|
||||
('response_headers', models.TextField()),
|
||||
('response_body', models.BinaryField()),
|
||||
],
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='webhookcall',
|
||||
options={'ordering': ('-datetime',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='webhookeventlistener',
|
||||
options={'ordering': ('action_type',)},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='apicall',
|
||||
unique_together={('idempotency_key', 'auth_hash')},
|
||||
),
|
||||
]
|
||||
@@ -77,6 +77,9 @@ class WebHook(models.Model):
|
||||
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
|
||||
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ('id',)
|
||||
|
||||
@property
|
||||
def action_types(self):
|
||||
return [
|
||||
@@ -106,3 +109,20 @@ class WebHookCall(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ("-datetime",)
|
||||
|
||||
|
||||
class ApiCall(models.Model):
|
||||
idempotency_key = models.CharField(max_length=190, db_index=True)
|
||||
auth_hash = models.CharField(max_length=190, db_index=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
locked = models.DateTimeField(null=True)
|
||||
|
||||
request_method = models.CharField(max_length=20)
|
||||
request_path = models.CharField(max_length=255)
|
||||
|
||||
response_code = models.PositiveIntegerField()
|
||||
response_headers = models.TextField()
|
||||
response_body = models.BinaryField()
|
||||
|
||||
class Meta:
|
||||
unique_together = (('idempotency_key', 'auth_hash'),)
|
||||
|
||||
@@ -31,10 +31,10 @@ class PluginsField(Field):
|
||||
def to_representation(self, obj):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
return {
|
||||
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 {
|
||||
@@ -164,6 +164,7 @@ class CloneEventSerializer(EventSerializer):
|
||||
def create(self, validated_data):
|
||||
plugins = validated_data.pop('plugins', None)
|
||||
is_public = validated_data.pop('is_public', None)
|
||||
testmode = validated_data.pop('testmode', None)
|
||||
new_event = super().create(validated_data)
|
||||
|
||||
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
|
||||
@@ -173,6 +174,8 @@ class CloneEventSerializer(EventSerializer):
|
||||
new_event.set_active_plugins(plugins)
|
||||
if is_public is not None:
|
||||
new_event.is_public = is_public
|
||||
if testmode is not None:
|
||||
new_event.testmode = testmode
|
||||
new_event.save()
|
||||
|
||||
return new_event
|
||||
@@ -191,17 +194,114 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer):
|
||||
|
||||
|
||||
class SubEventSerializer(I18nAwareModelSerializer):
|
||||
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True)
|
||||
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True)
|
||||
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True, required=False)
|
||||
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True, required=False)
|
||||
event = SlugRelatedField(slug_field='slug', read_only=True)
|
||||
meta_data = MetaDataField(source='*')
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
|
||||
'presale_start', 'presale_end', 'location', 'event',
|
||||
'presale_start', 'presale_end', 'location', 'event', 'is_public',
|
||||
'item_price_overrides', 'variation_price_overrides', 'meta_data')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
event = self.context['request'].event
|
||||
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
Event.clean_dates(data.get('date_from'), data.get('date_to'))
|
||||
Event.clean_presale(data.get('presale_start'), data.get('presale_end'))
|
||||
|
||||
SubEvent.clean_items(event, [item['item'] for item in full_data.get('subeventitem_set', [])])
|
||||
SubEvent.clean_variations(event, [item['variation'] for item in full_data.get('subeventitemvariation_set', [])])
|
||||
return data
|
||||
|
||||
def validate_item_price_overrides(self, data):
|
||||
return list(filter(lambda i: 'item' in i, data))
|
||||
|
||||
def validate_variation_price_overrides(self, data):
|
||||
return list(filter(lambda i: 'variation' in i, data))
|
||||
|
||||
@cached_property
|
||||
def meta_properties(self):
|
||||
return {
|
||||
p.name: p for p in self.context['request'].organizer.meta_properties.all()
|
||||
}
|
||||
|
||||
def validate_meta_data(self, value):
|
||||
for key in value['meta_data'].keys():
|
||||
if key not in self.meta_properties:
|
||||
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
|
||||
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
subevent = super().create(validated_data)
|
||||
|
||||
for item_price_override_data in item_price_overrides_data:
|
||||
SubEventItem.objects.create(subevent=subevent, **item_price_override_data)
|
||||
for variation_price_override_data in variation_price_overrides_data:
|
||||
SubEventItemVariation.objects.create(subevent=subevent, **variation_price_override_data)
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
for key, value in meta_data.items():
|
||||
subevent.meta_values.create(
|
||||
property=self.meta_properties.get(key),
|
||||
value=value
|
||||
)
|
||||
|
||||
return subevent
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
|
||||
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
subevent = super().update(instance, validated_data)
|
||||
|
||||
existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)}
|
||||
|
||||
for item_price_override_data in item_price_overrides_data:
|
||||
id = existing_item_overrides.pop(item_price_override_data['item'], None)
|
||||
SubEventItem(id=id, subevent=subevent, **item_price_override_data).save()
|
||||
|
||||
SubEventItem.objects.filter(id__in=existing_item_overrides.values()).delete()
|
||||
|
||||
existing_variation_overrides = {item.variation: item.id for item in SubEventItemVariation.objects.filter(subevent=subevent)}
|
||||
|
||||
for variation_price_override_data in variation_price_overrides_data:
|
||||
id = existing_variation_overrides.pop(variation_price_override_data['variation'], None)
|
||||
SubEventItemVariation(id=id, subevent=subevent, **variation_price_override_data).save()
|
||||
|
||||
SubEventItemVariation.objects.filter(id__in=existing_variation_overrides.values()).delete()
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
current = {mv.property: mv for mv in subevent.meta_values.select_related('property')}
|
||||
for key, value in meta_data.items():
|
||||
prop = self.meta_properties.get(key)
|
||||
if prop in current:
|
||||
current[prop].value = value
|
||||
current[prop].save()
|
||||
else:
|
||||
subevent.meta_values.create(
|
||||
property=self.meta_properties.get(key),
|
||||
value=value
|
||||
)
|
||||
|
||||
for prop, current_object in current.items():
|
||||
if prop.name not in meta_data:
|
||||
current_object.delete()
|
||||
|
||||
return subevent
|
||||
|
||||
|
||||
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -7,23 +7,36 @@ from rest_framework import serializers
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import (
|
||||
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
|
||||
Quota,
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question,
|
||||
QuestionOption, Quota,
|
||||
)
|
||||
|
||||
|
||||
class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
||||
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10,
|
||||
coerce_to_string=True)
|
||||
|
||||
class Meta:
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price')
|
||||
'position', 'default_price', 'price', 'original_price')
|
||||
|
||||
|
||||
class ItemVariationSerializer(I18nAwareModelSerializer):
|
||||
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10,
|
||||
coerce_to_string=True)
|
||||
|
||||
class Meta:
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price')
|
||||
'position', 'default_price', 'price', 'original_price')
|
||||
|
||||
|
||||
class InlineItemBundleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemBundle
|
||||
fields = ('bundled_item', 'bundled_variation', 'count',
|
||||
'designated_price')
|
||||
|
||||
|
||||
class InlineItemAddOnSerializer(serializers.ModelSerializer):
|
||||
@@ -33,6 +46,31 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
|
||||
'position', 'price_included')
|
||||
|
||||
|
||||
class ItemBundleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemBundle
|
||||
fields = ('id', 'bundled_item', 'bundled_variation', 'count',
|
||||
'designated_price')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
event = self.context['event']
|
||||
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
ItemBundle.clean_itemvar(event, full_data.get('bundled_item'), full_data.get('bundled_variation'))
|
||||
|
||||
item = self.context['item']
|
||||
if item == full_data.get('bundled_item'):
|
||||
raise ValidationError(_("The bundled item must not be the same item as the bundling one."))
|
||||
if full_data.get('bundled_item'):
|
||||
if full_data['bundled_item'].bundles.exists():
|
||||
raise ValidationError(_("The bundled item must not have bundles on its own."))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class ItemAddOnSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemAddOn
|
||||
@@ -69,6 +107,7 @@ class ItemTaxRateField(serializers.Field):
|
||||
|
||||
class ItemSerializer(I18nAwareModelSerializer):
|
||||
addons = InlineItemAddOnSerializer(many=True, required=False)
|
||||
bundles = InlineItemBundleSerializer(many=True, required=False)
|
||||
variations = InlineItemVariationSerializer(many=True, required=False)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
||||
|
||||
@@ -77,9 +116,9 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description',
|
||||
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission',
|
||||
'position', 'picture', 'available_from', 'available_until',
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
|
||||
'variations', 'addons', 'original_price', 'require_approval', 'generate_tickets')
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
|
||||
def get_serializer_context(self):
|
||||
@@ -87,8 +126,8 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
if self.instance and ('addons' in data or 'variations' in data):
|
||||
raise ValidationError(_('Updating add-ons or variations via PATCH/PUT is not supported. Please use the '
|
||||
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data):
|
||||
raise ValidationError(_('Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use the '
|
||||
'dedicated nested endpoint.'))
|
||||
|
||||
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
|
||||
@@ -104,6 +143,12 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
Item.clean_tax_rule(value, self.context['event'])
|
||||
return value
|
||||
|
||||
def validate_bundles(self, value):
|
||||
if not self.instance:
|
||||
for b_data in value:
|
||||
ItemBundle.clean_itemvar(self.context['event'], b_data['bundled_item'], b_data['bundled_variation'])
|
||||
return value
|
||||
|
||||
def validate_addons(self, value):
|
||||
if not self.instance:
|
||||
for addon_data in value:
|
||||
@@ -117,11 +162,14 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
def create(self, validated_data):
|
||||
variations_data = validated_data.pop('variations') if 'variations' in validated_data else {}
|
||||
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
|
||||
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
|
||||
item = Item.objects.create(**validated_data)
|
||||
for variation_data in variations_data:
|
||||
ItemVariation.objects.create(item=item, **variation_data)
|
||||
for addon_data in addons_data:
|
||||
ItemAddOn.objects.create(base_item=item, **addon_data)
|
||||
for bundle_data in bundles_data:
|
||||
ItemBundle.objects.create(base_item=item, **bundle_data)
|
||||
return item
|
||||
|
||||
|
||||
@@ -159,12 +207,21 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
|
||||
'ask_during_checkin', 'identifier')
|
||||
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_value',
|
||||
'hidden')
|
||||
|
||||
def validate_identifier(self, value):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
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):
|
||||
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
|
||||
if value == self.instance:
|
||||
raise ValidationError('A question cannot depend on itself.')
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
if self.instance and 'options' in data:
|
||||
@@ -176,6 +233,18 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
||||
raise ValidationError('Dependencies are not supported during check-in.')
|
||||
|
||||
dep = full_data.get('dependency_question')
|
||||
if dep:
|
||||
seen_ids = {self.instance.pk} if self.instance else set()
|
||||
while dep:
|
||||
if dep.pk in seen_ids:
|
||||
raise ValidationError(_('Circular dependency between questions detected.'))
|
||||
seen_ids.add(dep.pk)
|
||||
dep = dep.dependency_question
|
||||
|
||||
Question.clean_items(event, full_data.get('items'))
|
||||
return data
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
||||
Question, QuestionAnswer,
|
||||
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
|
||||
OrderPosition, Question, QuestionAnswer, SubEvent,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
@@ -179,6 +179,55 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
self.fields.pop('pdf_data')
|
||||
|
||||
|
||||
class RequireAttentionField(serializers.Field):
|
||||
def to_representation(self, instance: OrderPosition):
|
||||
return instance.order.checkin_attention or instance.item.checkin_attention
|
||||
|
||||
|
||||
class AttendeeNameField(serializers.Field):
|
||||
def to_representation(self, instance: OrderPosition):
|
||||
an = instance.attendee_name
|
||||
if not an:
|
||||
if instance.addon_to_id:
|
||||
an = instance.addon_to.attendee_name
|
||||
if not an:
|
||||
try:
|
||||
an = instance.order.invoice_address.name
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
return an
|
||||
|
||||
|
||||
class AttendeeNamePartsField(serializers.Field):
|
||||
def to_representation(self, instance: OrderPosition):
|
||||
an = instance.attendee_name
|
||||
p = instance.attendee_name_parts
|
||||
if not an:
|
||||
if instance.addon_to_id:
|
||||
an = instance.addon_to.attendee_name
|
||||
p = instance.addon_to.attendee_name_parts
|
||||
if not an:
|
||||
try:
|
||||
p = instance.order.invoice_address.name_parts
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
return p
|
||||
|
||||
|
||||
class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
||||
require_attention = RequireAttentionField(source='*')
|
||||
attendee_name = AttendeeNameField(source='*')
|
||||
attendee_name_parts = AttendeeNamePartsField(source='*')
|
||||
order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order')
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'require_attention',
|
||||
'order__status')
|
||||
|
||||
|
||||
class OrderPaymentTypeField(serializers.Field):
|
||||
# TODO: Remove after pretix 2.2
|
||||
def to_representation(self, instance: Order):
|
||||
@@ -220,26 +269,90 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
|
||||
|
||||
|
||||
class OrderSerializer(I18nAwareModelSerializer):
|
||||
invoice_address = InvoiceAddressSerializer()
|
||||
positions = OrderPositionSerializer(many=True)
|
||||
fees = OrderFeeSerializer(many=True)
|
||||
downloads = OrderDownloadsField(source='*')
|
||||
payments = OrderPaymentSerializer(many=True)
|
||||
refunds = OrderRefundSerializer(many=True)
|
||||
payment_date = OrderPaymentDateField(source='*')
|
||||
payment_provider = OrderPaymentTypeField(source='*')
|
||||
invoice_address = InvoiceAddressSerializer(allow_null=True)
|
||||
positions = OrderPositionSerializer(many=True, read_only=True)
|
||||
fees = OrderFeeSerializer(many=True, read_only=True)
|
||||
downloads = OrderDownloadsField(source='*', read_only=True)
|
||||
payments = OrderPaymentSerializer(many=True, read_only=True)
|
||||
refunds = OrderRefundSerializer(many=True, read_only=True)
|
||||
payment_date = OrderPaymentDateField(source='*', read_only=True)
|
||||
payment_provider = OrderPaymentTypeField(source='*', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ('code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
||||
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel')
|
||||
fields = (
|
||||
'code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
||||
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel'
|
||||
)
|
||||
read_only_fields = (
|
||||
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'positions', 'downloads',
|
||||
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
|
||||
self.fields['positions'].child.fields.pop('pdf_data')
|
||||
|
||||
def validate_locale(self, l):
|
||||
if l not in set(k for k in self.instance.event.settings.locales):
|
||||
raise ValidationError('"{}" is not a supported locale for this event.'.format(l))
|
||||
return l
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
|
||||
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
|
||||
update_fields = ['comment', 'checkin_attention', 'email', 'locale']
|
||||
print(validated_data)
|
||||
|
||||
if 'invoice_address' in validated_data:
|
||||
iadata = validated_data.pop('invoice_address')
|
||||
|
||||
if not iadata:
|
||||
try:
|
||||
instance.invoice_address.delete()
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
name = iadata.pop('name', '')
|
||||
if name and not iadata.get('name_parts'):
|
||||
iadata['name_parts'] = {
|
||||
'_legacy': name
|
||||
}
|
||||
try:
|
||||
ia = instance.invoice_address
|
||||
if iadata.get('vat_id') != ia.vat_id:
|
||||
ia.vat_id_validated = False
|
||||
self.fields['invoice_address'].update(ia, iadata)
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
InvoiceAddress.objects.create(order=instance, **iadata)
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
if attr in update_fields:
|
||||
setattr(instance, attr, value)
|
||||
|
||||
instance.save(update_fields=update_fields)
|
||||
return instance
|
||||
|
||||
|
||||
class PriceCalcSerializer(serializers.Serializer):
|
||||
item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
|
||||
variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
|
||||
subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True)
|
||||
locale = serializers.CharField(allow_null=True, required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['item'].queryset = event.items.all()
|
||||
self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=event)
|
||||
if event.has_subevents:
|
||||
self.fields['subevent'].queryset = event.subevents.all()
|
||||
else:
|
||||
del self.fields['subevent']
|
||||
|
||||
|
||||
class AnswerCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -410,11 +523,13 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
payment_provider = serializers.CharField(required=True)
|
||||
payment_info = CompatibleJSONField(required=False)
|
||||
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
force = serializers.BooleanField(default=False, required=False)
|
||||
payment_date = serializers.DateTimeField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts')
|
||||
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts', 'force')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
if pp not in self.context['event'].get_payment_providers():
|
||||
@@ -485,6 +600,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
|
||||
payment_provider = validated_data.pop('payment_provider')
|
||||
payment_info = validated_data.pop('payment_info', '{}')
|
||||
payment_date = validated_data.pop('payment_date', now())
|
||||
force = validated_data.pop('force', False)
|
||||
|
||||
if 'invoice_address' in validated_data:
|
||||
iadata = validated_data.pop('invoice_address')
|
||||
@@ -518,29 +635,30 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
errs = [{} for p in positions_data]
|
||||
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
|
||||
if pos_data.get('variation')
|
||||
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
|
||||
if len(new_quotas) == 0:
|
||||
errs[i]['item'] = [ugettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
else:
|
||||
for quota in new_quotas:
|
||||
if quota not in quota_avail_cache:
|
||||
quota_avail_cache[quota] = list(quota.availability())
|
||||
if not force:
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
|
||||
if pos_data.get('variation')
|
||||
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
|
||||
if len(new_quotas) == 0:
|
||||
errs[i]['item'] = [ugettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
else:
|
||||
for quota in new_quotas:
|
||||
if quota not in quota_avail_cache:
|
||||
quota_avail_cache[quota] = list(quota.availability())
|
||||
|
||||
if quota_avail_cache[quota][1] is not None:
|
||||
quota_avail_cache[quota][1] -= 1
|
||||
if quota_avail_cache[quota][1] < 0:
|
||||
errs[i]['item'] = [
|
||||
ugettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
|
||||
quota.name
|
||||
)
|
||||
]
|
||||
if quota_avail_cache[quota][1] is not None:
|
||||
quota_avail_cache[quota][1] -= 1
|
||||
if quota_avail_cache[quota][1] < 0:
|
||||
errs[i]['item'] = [
|
||||
ugettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
|
||||
quota.name
|
||||
)
|
||||
]
|
||||
|
||||
quotadiff.update(new_quotas)
|
||||
quotadiff.update(new_quotas)
|
||||
|
||||
if any(errs):
|
||||
raise ValidationError({'positions': errs})
|
||||
@@ -567,7 +685,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
amount=order.total,
|
||||
provider=payment_provider,
|
||||
info=payment_info,
|
||||
payment_date=now(),
|
||||
payment_date=payment_date,
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
)
|
||||
elif payment_provider:
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import timedelta
|
||||
from django.dispatch import Signal, receiver
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix.api.models import WebHookCall
|
||||
from pretix.api.models import ApiCall, WebHookCall
|
||||
from pretix.base.signals import periodic_task
|
||||
|
||||
register_webhook_events = Signal(
|
||||
@@ -19,3 +19,8 @@ instances.
|
||||
@receiver(periodic_task)
|
||||
def cleanup_webhook_logs(sender, **kwargs):
|
||||
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()
|
||||
|
||||
|
||||
@receiver(periodic_task)
|
||||
def cleanup_api_logs(sender, **kwargs):
|
||||
ApiCall.objects.filter(created__lte=now() - timedelta(hours=24)).delete()
|
||||
|
||||
@@ -44,6 +44,7 @@ question_router.register(r'options', item.QuestionOptionViewSet)
|
||||
item_router = routers.DefaultRouter()
|
||||
item_router.register(r'variations', item.ItemVariationViewSet)
|
||||
item_router.register(r'addons', item.ItemAddOnViewSet)
|
||||
item_router.register(r'bundles', item.ItemBundleViewSet)
|
||||
|
||||
order_router = routers.DefaultRouter()
|
||||
order_router.register(r'payments', order.PaymentViewSet)
|
||||
|
||||
@@ -31,10 +31,10 @@ class RichOrderingFilter(OrderingFilter):
|
||||
class ConditionalListView:
|
||||
|
||||
def list(self, request, **kwargs):
|
||||
if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE')
|
||||
if_modified_since = request.headers.get('If-Modified-Since')
|
||||
if if_modified_since:
|
||||
if_modified_since = parse_http_date_safe(if_modified_since)
|
||||
if_unmodified_since = request.META.get('HTTP_IF_UNMODIFIED_SINCE')
|
||||
if_unmodified_since = request.headers.get('If-Unmodified-Since')
|
||||
if if_unmodified_since:
|
||||
if_unmodified_since = parse_http_date_safe(if_unmodified_since)
|
||||
if not hasattr(request, 'event'):
|
||||
|
||||
@@ -7,13 +7,13 @@ from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import DateTimeField
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.checkin import CheckinListSerializer
|
||||
from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import OrderPositionSerializer
|
||||
from pretix.api.serializers.order import CheckinListOrderPositionSerializer
|
||||
from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.models import (
|
||||
@@ -77,7 +77,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@detail_route(methods=['GET'])
|
||||
@action(detail=True, methods=['GET'])
|
||||
def status(self, *args, **kwargs):
|
||||
clist = self.get_object()
|
||||
cqs = Checkin.objects.filter(
|
||||
@@ -153,7 +153,7 @@ class CheckinOrderPositionFilter(OrderPositionFilter):
|
||||
|
||||
|
||||
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPositionSerializer
|
||||
serializer_class = CheckinListOrderPositionSerializer
|
||||
queryset = OrderPosition.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
|
||||
ordering = ('attendee_name_cached', 'positionid')
|
||||
@@ -189,7 +189,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
except ValueError:
|
||||
raise Http404()
|
||||
|
||||
def get_queryset(self):
|
||||
def get_queryset(self, ignore_status=False):
|
||||
cqs = Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
list_id=self.checkinlist.pk
|
||||
@@ -199,11 +199,15 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.request.event,
|
||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.checkinlist.include_pending else [Order.STATUS_PAID],
|
||||
subevent=self.checkinlist.subevent
|
||||
).annotate(
|
||||
last_checked_in=Subquery(cqs)
|
||||
)
|
||||
|
||||
if self.request.query_params.get('ignore_status', 'false') != 'true' and not ignore_status:
|
||||
qs = qs.filter(
|
||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.checkinlist.include_pending else [Order.STATUS_PAID]
|
||||
)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
@@ -225,7 +229,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to'
|
||||
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address'
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
@@ -235,19 +239,19 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address')
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order')
|
||||
|
||||
if not self.checkinlist.all_products:
|
||||
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
|
||||
|
||||
return qs
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def redeem(self, *args, **kwargs):
|
||||
force = bool(self.request.data.get('force', False))
|
||||
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
|
||||
nonce = self.request.data.get('nonce')
|
||||
op = self.get_object()
|
||||
op = self.get_object(ignore_status=True)
|
||||
|
||||
if 'datetime' in self.request.data:
|
||||
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
|
||||
@@ -281,7 +285,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
return Response({
|
||||
'status': 'incomplete',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data,
|
||||
'questions': [
|
||||
QuestionSerializer(q).data for q in e.questions
|
||||
]
|
||||
@@ -291,17 +295,17 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
'status': 'error',
|
||||
'reason': e.code,
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=400)
|
||||
else:
|
||||
return Response({
|
||||
'status': 'ok',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=201)
|
||||
|
||||
def get_object(self):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
def get_object(self, ignore_status=False):
|
||||
queryset = self.filter_queryset(self.get_queryset(ignore_status=ignore_status))
|
||||
if self.kwargs['pk'].isnumeric():
|
||||
obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
||||
else:
|
||||
|
||||
@@ -105,7 +105,7 @@ class RevokeKeyView(APIView):
|
||||
|
||||
def post(self, request, format=None):
|
||||
device = request.auth
|
||||
device.api_token = None
|
||||
device.revoked = True
|
||||
device.save()
|
||||
device.log_action('pretix.device.revoked', auth=device)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from pretix.api.serializers.event import (
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
Device, Event, ItemCategory, TaxRule, TeamAPIToken,
|
||||
CartPosition, Device, Event, ItemCategory, TaxRule, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
@@ -72,6 +72,8 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
lookup_url_kwarg = 'event'
|
||||
permission_classes = (EventCRUDPermission,)
|
||||
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
|
||||
ordering = ('slug',)
|
||||
ordering_fields = ('date_from', 'slug')
|
||||
filterset_class = EventFilter
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -217,9 +219,10 @@ class SubEventFilter(FilterSet):
|
||||
return queryset.exclude(expr)
|
||||
|
||||
|
||||
class SubEventViewSet(ConditionalListView, viewsets.ReadOnlyModelViewSet):
|
||||
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = SubEventSerializer
|
||||
queryset = ItemCategory.objects.none()
|
||||
write_permission = 'can_change_event_settings'
|
||||
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
|
||||
filterset_class = SubEventFilter
|
||||
|
||||
@@ -240,6 +243,44 @@ class SubEventViewSet(ConditionalListView, viewsets.ReadOnlyModelViewSet):
|
||||
'subeventitem_set', 'subeventitemvariation_set'
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
super().perform_update(serializer)
|
||||
|
||||
serializer.instance.log_action(
|
||||
'pretix.subevent.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.subevent.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied('The sub-event can not be deleted as it has already been used in orders. Please set'
|
||||
' \'active\' to false instead to hide it from users.')
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance.log_action(
|
||||
'pretix.subevent.deleted',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
CartPosition.objects.filter(addon_to__subevent=instance).delete()
|
||||
instance.cartposition_set.all().delete()
|
||||
super().perform_destroy(instance)
|
||||
except ProtectedError:
|
||||
raise PermissionDenied('The sub-event could not be deleted as some constraints (e.g. data created by '
|
||||
'plug-ins) do not allow it.')
|
||||
|
||||
|
||||
class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = TaxRuleSerializer
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.item import (
|
||||
ItemAddOnSerializer, ItemCategorySerializer, ItemSerializer,
|
||||
ItemVariationSerializer, QuestionOptionSerializer, QuestionSerializer,
|
||||
QuotaSerializer,
|
||||
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
||||
ItemSerializer, ItemVariationSerializer, QuestionOptionSerializer,
|
||||
QuestionSerializer, QuotaSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
|
||||
Quota,
|
||||
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation,
|
||||
Question, QuestionOption, Quota,
|
||||
)
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
@@ -46,7 +47,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.items.select_related('tax_rule').prefetch_related('variations', 'addons').all()
|
||||
return self.request.event.items.select_related('tax_rule').prefetch_related('variations', 'addons', 'bundles').all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
@@ -83,7 +84,8 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
self.get_object().cartposition_set.all().delete()
|
||||
CartPosition.objects.filter(addon_to__item=instance).delete()
|
||||
instance.cartposition_set.all().delete()
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
@@ -96,17 +98,20 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
@cached_property
|
||||
def item(self):
|
||||
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
|
||||
def get_queryset(self):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
return item.variations.all()
|
||||
return self.item.variations.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['item'] = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
ctx['item'] = self.item
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
item = self.item
|
||||
if not item.has_variations:
|
||||
raise PermissionDenied('This variation cannot be created because the item does not have variations. '
|
||||
'Changing a product without variations to a product with variations is not allowed.')
|
||||
@@ -149,6 +154,58 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
|
||||
class ItemBundleViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ItemBundleSerializer
|
||||
queryset = ItemBundle.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
ordering_fields = ('id',)
|
||||
ordering = ('id',)
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
@cached_property
|
||||
def item(self):
|
||||
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.item.bundles.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['item'] = self.item
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
serializer.save(base_item=item)
|
||||
item.log_action(
|
||||
'pretix.event.item.bundles.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.base_item.log_action(
|
||||
'pretix.event.item.bundles.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
super().perform_destroy(instance)
|
||||
instance.base_item.log_action(
|
||||
'pretix.event.item.bundles.removed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'bundled_item': instance.bundled_item.pk, 'bundled_variation': instance.bundled_variation.pk if instance.bundled_variation else None,
|
||||
'count': instance.count, 'designated_price': instance.designated_price}
|
||||
)
|
||||
|
||||
|
||||
class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ItemAddOnSerializer
|
||||
queryset = ItemAddOn.objects.none()
|
||||
@@ -158,18 +215,21 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
@cached_property
|
||||
def item(self):
|
||||
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
|
||||
def get_queryset(self):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
return item.addons.all()
|
||||
return self.item.addons.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['item'] = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
ctx['item'] = self.item
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
item = self.item
|
||||
category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category'])
|
||||
serializer.save(base_item=item, addon_category=category)
|
||||
item.log_action(
|
||||
@@ -439,7 +499,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@detail_route(methods=['get'])
|
||||
@action(detail=True, methods=['get'])
|
||||
def availability(self, request, *args, **kwargs):
|
||||
quota = self.get_object()
|
||||
|
||||
|
||||
@@ -9,27 +9,31 @@ from django.db.models.functions import Coalesce, Concat
|
||||
from django.http import FileResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import ugettext as _
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import mixins, serializers, status, viewsets
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import (
|
||||
APIException, NotFound, PermissionDenied, ValidationError,
|
||||
)
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
||||
from rest_framework.mixins import CreateModelMixin
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.serializers.order import (
|
||||
InvoiceSerializer, OrderCreateSerializer, OrderPaymentSerializer,
|
||||
OrderPositionSerializer, OrderRefundCreateSerializer,
|
||||
OrderRefundSerializer, OrderSerializer,
|
||||
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer,
|
||||
)
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, Order,
|
||||
OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
|
||||
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
|
||||
Order, OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
|
||||
generate_position_secret, generate_secret,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.services import tickets
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
||||
regenerate_invoice,
|
||||
@@ -39,8 +43,12 @@ from pretix.base.services.orders import (
|
||||
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
|
||||
extend_order, mark_order_expired, mark_order_refunded,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.tickets import generate
|
||||
from pretix.base.signals import order_placed, register_ticket_outputs
|
||||
from pretix.base.signals import (
|
||||
order_modified, order_placed, register_ticket_outputs,
|
||||
)
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
|
||||
|
||||
class OrderFilter(FilterSet):
|
||||
@@ -48,13 +56,14 @@ class OrderFilter(FilterSet):
|
||||
code = django_filters.CharFilter(field_name='code', lookup_expr='iexact')
|
||||
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
|
||||
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
|
||||
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
|
||||
|
||||
|
||||
class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
class OrderViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = OrderSerializer
|
||||
queryset = Order.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
@@ -122,7 +131,7 @@ class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelVi
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data, headers={'X-Page-Generated': date})
|
||||
|
||||
@detail_route(url_name='download', url_path='download/(?P<output>[^/]+)')
|
||||
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
|
||||
def download(self, request, output, **kwargs):
|
||||
provider = self._get_output_provider(output)
|
||||
order = self.get_object()
|
||||
@@ -144,7 +153,7 @@ class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelVi
|
||||
)
|
||||
return resp
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def mark_paid(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
|
||||
@@ -185,7 +194,7 @@ class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelVi
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def mark_canceled(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
cancellation_fee = request.data.get('cancellation_fee', None)
|
||||
@@ -219,7 +228,7 @@ class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelVi
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def approve(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
|
||||
@@ -237,7 +246,7 @@ class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelVi
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def deny(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
comment = request.data.get('comment', '')
|
||||
@@ -255,7 +264,7 @@ class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelVi
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def mark_pending(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
|
||||
@@ -274,7 +283,7 @@ class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelVi
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def mark_expired(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
|
||||
@@ -291,7 +300,7 @@ class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelVi
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def mark_refunded(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
|
||||
@@ -308,7 +317,73 @@ class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelVi
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def create_invoice(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
has_inv = order.invoices.exists() and not (
|
||||
order.status in (Order.STATUS_PAID, Order.STATUS_PENDING)
|
||||
and order.invoices.filter(is_cancellation=True).count() >= order.invoices.filter(is_cancellation=False).count()
|
||||
)
|
||||
if self.request.event.settings.get('invoice_generate') not in ('admin', 'user', 'paid', 'True') or not invoice_qualified(order):
|
||||
return Response(
|
||||
{'detail': _('You cannot generate an invoice for this order.')},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
elif has_inv:
|
||||
return Response(
|
||||
{'detail': _('An invoice for this order already exists.')},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
inv = generate_invoice(order)
|
||||
order.log_action(
|
||||
'pretix.event.order.invoice.generated',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'invoice': inv.pk
|
||||
}
|
||||
)
|
||||
return Response(
|
||||
InvoiceSerializer(inv).data,
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def resend_link(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
if not order.email:
|
||||
return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
order.resend_link(user=self.request.user, auth=self.request.auth)
|
||||
except SendMailException:
|
||||
return Response({'detail': _('There was an error sending the mail. Please try again later.')}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
|
||||
return Response(
|
||||
status=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
@transaction.atomic
|
||||
def regenerate_secrets(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
order.secret = generate_secret()
|
||||
for op in order.all_positions.all():
|
||||
op.secret = generate_position_secret()
|
||||
op.save()
|
||||
order.save(update_fields=['secret'])
|
||||
CachedTicket.objects.filter(order_position__order=order).delete()
|
||||
CachedCombinedTicket.objects.filter(order=order).delete()
|
||||
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk,
|
||||
'order': order.pk})
|
||||
order.log_action(
|
||||
'pretix.event.order.secret.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def extend(self, request, **kwargs):
|
||||
new_date = request.data.get('expires', None)
|
||||
force = request.data.get('force', False)
|
||||
@@ -375,6 +450,76 @@ class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelVi
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
partial = kwargs.get('partial', False)
|
||||
if not partial:
|
||||
return Response(
|
||||
{"detail": "Method \"PUT\" not allowed."},
|
||||
status=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
with transaction.atomic():
|
||||
if 'comment' in self.request.data and serializer.instance.comment != self.request.data.get('comment'):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.order.comment',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'new_comment': self.request.data.get('comment')
|
||||
}
|
||||
)
|
||||
|
||||
if 'checkin_attention' in self.request.data and serializer.instance.checkin_attention != self.request.data.get('checkin_attention'):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.order.checkin_attention',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'new_value': self.request.data.get('checkin_attention')
|
||||
}
|
||||
)
|
||||
|
||||
if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'):
|
||||
serializer.instance.email_known_to_work = False
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.order.contact.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'old_email': serializer.instance.email,
|
||||
'new_email': self.request.data.get('email'),
|
||||
}
|
||||
)
|
||||
|
||||
if 'locale' in self.request.data and serializer.instance.locale != self.request.data.get('locale'):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.order.locale.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'old_locale': serializer.instance.locale,
|
||||
'new_locale': self.request.data.get('locale'),
|
||||
}
|
||||
)
|
||||
|
||||
if 'invoice_address' in self.request.data:
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.order.modified',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'invoice_data': self.request.data.get('invoice_address'),
|
||||
}
|
||||
)
|
||||
|
||||
serializer.save()
|
||||
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.event.pk, 'order': serializer.instance.pk})
|
||||
|
||||
if 'invoice_address' in self.request.data:
|
||||
order_modified.send(sender=serializer.instance.event, order=serializer.instance)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
@@ -482,7 +627,84 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
return prov
|
||||
raise NotFound('Unknown output provider.')
|
||||
|
||||
@detail_route(url_name='download', url_path='download/(?P<output>[^/]+)')
|
||||
@action(detail=True, methods=['POST'], url_name='price_calc')
|
||||
def price_calc(self, request, *args, **kwargs):
|
||||
"""
|
||||
This calculates the price assuming a change of product or subevent. This endpoint
|
||||
is deliberately not documented and considered a private API, only to be used by
|
||||
pretix' web interface.
|
||||
|
||||
Sample input:
|
||||
|
||||
{
|
||||
"item": 2,
|
||||
"variation": null,
|
||||
"subevent": 3
|
||||
}
|
||||
|
||||
Sample output:
|
||||
|
||||
{
|
||||
"gross": "2.34",
|
||||
"gross_formatted": "2,34",
|
||||
"net": "2.34",
|
||||
"tax": "0.00",
|
||||
"rate": "0.00",
|
||||
"name": "VAT"
|
||||
}
|
||||
"""
|
||||
serializer = PriceCalcSerializer(data=request.data, event=request.event)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
pos = self.get_object()
|
||||
|
||||
try:
|
||||
ia = pos.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = InvoiceAddress()
|
||||
|
||||
kwargs = {
|
||||
'item': pos.item,
|
||||
'variation': pos.variation,
|
||||
'voucher': pos.voucher,
|
||||
'subevent': pos.subevent,
|
||||
'addon_to': pos.addon_to,
|
||||
'invoice_address': ia,
|
||||
}
|
||||
|
||||
if data.get('item'):
|
||||
item = data.get('item')
|
||||
kwargs['item'] = item
|
||||
|
||||
if item.has_variations:
|
||||
variation = data.get('variation') or pos.variation
|
||||
if not variation:
|
||||
raise ValidationError('No variation given')
|
||||
if variation.item != item:
|
||||
raise ValidationError('Variation does not belong to item')
|
||||
kwargs['variation'] = variation
|
||||
else:
|
||||
variation = None
|
||||
kwargs['variation'] = None
|
||||
|
||||
if pos.voucher and not pos.voucher.applies_to(item, variation):
|
||||
kwargs['voucher'] = None
|
||||
|
||||
if data.get('subevent'):
|
||||
kwargs['subevent'] = data.get('subevent')
|
||||
|
||||
price = get_price(**kwargs)
|
||||
with language(data.get('locale') or self.request.event.settings.locale):
|
||||
return Response({
|
||||
'gross': price.gross,
|
||||
'gross_formatted': money_filter(price.gross, self.request.event.currency, hide_currency=True),
|
||||
'net': price.net,
|
||||
'rate': price.rate,
|
||||
'name': str(price.name),
|
||||
'tax': price.tax,
|
||||
})
|
||||
|
||||
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
|
||||
def download(self, request, output, **kwargs):
|
||||
provider = self._get_output_provider(output)
|
||||
pos = self.get_object()
|
||||
@@ -533,7 +755,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
||||
return order.payments.all()
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def confirm(self, request, **kwargs):
|
||||
payment = self.get_object()
|
||||
force = request.data.get('force', False)
|
||||
@@ -554,7 +776,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
pass
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def refund(self, request, **kwargs):
|
||||
payment = self.get_object()
|
||||
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
||||
@@ -619,7 +841,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
payment.order.save(update_fields=['status', 'expires'])
|
||||
return Response(OrderRefundSerializer(r).data, status=status.HTTP_200_OK)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def cancel(self, request, **kwargs):
|
||||
payment = self.get_object()
|
||||
|
||||
@@ -647,7 +869,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
||||
return order.refunds.all()
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def cancel(self, request, **kwargs):
|
||||
refund = self.get_object()
|
||||
|
||||
@@ -664,7 +886,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def process(self, request, **kwargs):
|
||||
refund = self.get_object()
|
||||
|
||||
@@ -689,7 +911,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
refund.order.save(update_fields=['status', 'expires'])
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def done(self, request, **kwargs):
|
||||
refund = self.get_object()
|
||||
|
||||
@@ -779,7 +1001,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
nr=Concat('prefix', 'invoice_no')
|
||||
)
|
||||
|
||||
@detail_route()
|
||||
@action(detail=True, )
|
||||
def download(self, request, **kwargs):
|
||||
invoice = self.get_object()
|
||||
|
||||
@@ -797,7 +1019,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
|
||||
return resp
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def regenerate(self, request, **kwarts):
|
||||
inv = self.get_object()
|
||||
if inv.canceled:
|
||||
@@ -816,7 +1038,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
return Response(status=204)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def reissue(self, request, **kwarts):
|
||||
inv = self.get_object()
|
||||
if inv.canceled:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from rest_framework import viewsets
|
||||
from rest_framework import filters, viewsets
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.serializers.organizer import OrganizerSerializer
|
||||
@@ -10,6 +10,9 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = Organizer.objects.none()
|
||||
lookup_field = 'slug'
|
||||
lookup_url_kwarg = 'organizer'
|
||||
filter_backends = (filters.OrderingFilter,)
|
||||
ordering = ('slug',)
|
||||
ordering_fields = ('name', 'slug')
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_authenticated:
|
||||
|
||||
@@ -7,7 +7,7 @@ from django_filters.rest_framework import (
|
||||
BooleanFilter, DjangoFilterBackend, FilterSet,
|
||||
)
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import list_route
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.response import Response
|
||||
@@ -111,9 +111,12 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
with transaction.atomic():
|
||||
instance.cartposition_set.filter(addon_to__isnull=False).delete()
|
||||
instance.cartposition_set.all().delete()
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@list_route(methods=['POST'])
|
||||
@action(detail=False, methods=['POST'])
|
||||
def batch_create(self, request, *args, **kwargs):
|
||||
if any(self._predict_quota_check(d, None) for d in request.data):
|
||||
lockfn = request.event.lock
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import django_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.response import Response
|
||||
@@ -69,7 +69,7 @@ class WaitingListViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@action(detail=True, methods=['POST'])
|
||||
def send_voucher(self, *args, **kwargs):
|
||||
try:
|
||||
self.get_object().send_voucher(
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.template.loader import get_template
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from inlinestyler.utils import inline_css
|
||||
|
||||
from pretix.base.models import Event, Order
|
||||
from pretix.base.models import Event, Order, OrderPosition
|
||||
from pretix.base.signals import register_html_mail_renderers
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
|
||||
@@ -44,7 +44,8 @@ class BaseHTMLMailRenderer:
|
||||
def __str__(self):
|
||||
return self.identifier
|
||||
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None) -> str:
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None,
|
||||
position: OrderPosition=None) -> str:
|
||||
"""
|
||||
This method should generate the HTML part of the email.
|
||||
|
||||
@@ -52,6 +53,7 @@ class BaseHTMLMailRenderer:
|
||||
:param plain_signature: The signature with event organizer contact details in plain text.
|
||||
:param subject: The email subject.
|
||||
:param order: The order if this email is connected to one, otherwise ``None``.
|
||||
:param position: The order position if this email is connected to one, otherwise ``None``.
|
||||
:return: An HTML string
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -95,7 +97,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
def template_name(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order) -> str:
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order, position: OrderPosition) -> str:
|
||||
body_md = markdown_compile_email(plain_body)
|
||||
htmlctx = {
|
||||
'site': settings.PRETIX_INSTANCE_NAME,
|
||||
@@ -116,6 +118,9 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
if order:
|
||||
htmlctx['order'] = order
|
||||
|
||||
if position:
|
||||
htmlctx['position'] = position
|
||||
|
||||
tpl = get_template(self.template_name)
|
||||
body_html = inline_css(tpl.render(htmlctx))
|
||||
return body_html
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .answers import * # noqa
|
||||
from .dekodi import * # noqa
|
||||
from .invoices import * # noqa
|
||||
from .json import * # noqa
|
||||
from .mail import * # noqa
|
||||
|
||||
@@ -40,6 +40,7 @@ class AnswerFilesExporter(BaseExporter):
|
||||
if form_data.get('questions'):
|
||||
qs = qs.filter(question__in=form_data['questions'])
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
any = False
|
||||
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for i in qs:
|
||||
if i.file:
|
||||
@@ -51,9 +52,12 @@ class AnswerFilesExporter(BaseExporter):
|
||||
i.question.pk,
|
||||
os.path.basename(i.file.name).split('.', 1)[1]
|
||||
)
|
||||
any = True
|
||||
zipf.writestr(fname, i.file.read())
|
||||
i.file.close()
|
||||
|
||||
if not any:
|
||||
return None
|
||||
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
|
||||
return '{}_answers.zip'.format(self.event.slug), 'application/zip', zipf.read()
|
||||
|
||||
|
||||
219
src/pretix/base/exporters/dekodi.py
Normal file
219
src/pretix/base/exporters/dekodi.py
Normal file
@@ -0,0 +1,219 @@
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
|
||||
import dateutil
|
||||
from django import forms
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext, ugettext_lazy
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Invoice, OrderPayment
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..signals import register_data_exporters
|
||||
|
||||
|
||||
class DekodiNREIExporter(BaseExporter):
|
||||
identifier = 'dekodi_nrei'
|
||||
verbose_name = 'dekodi NREI (JSON)'
|
||||
|
||||
# Specification: http://manuals.dekodi.de/nexuspub/schnittstellenbuch/
|
||||
|
||||
def _encode_invoice(self, invoice: Invoice):
|
||||
p_last = invoice.order.payments.filter(state=[OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED]).last()
|
||||
gross_total = Decimal('0.00')
|
||||
net_total = Decimal('0.00')
|
||||
|
||||
positions = []
|
||||
for l in invoice.lines.all():
|
||||
positions.append({
|
||||
'ADes': l.description.replace("<br />", "\n"),
|
||||
'ANetA': round(float((-1 if invoice.is_cancellation else 1) * l.net_value), 2),
|
||||
'ANo': self.event.slug,
|
||||
'AQ': -1 if invoice.is_cancellation else 1,
|
||||
'AVatP': round(float(l.tax_rate), 2),
|
||||
'DIDt': (l.subevent or invoice.order.event).date_from.isoformat().replace('Z', '+00:00'),
|
||||
'PosGrossA': round(float(l.gross_value), 2),
|
||||
'PosNetA': round(float(l.net_value), 2),
|
||||
})
|
||||
gross_total += l.gross_value
|
||||
net_total += l.net_value
|
||||
|
||||
payments = []
|
||||
paypal_email = None
|
||||
for p in invoice.order.payments.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_PENDING,
|
||||
OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_REFUNDED)
|
||||
):
|
||||
if p.provider == 'paypal':
|
||||
paypal_email = p.info_data.get('payer', {}).get('payer_info', {}).get('email')
|
||||
try:
|
||||
ppid = p.info_data['transactions'][0]['related_resources'][0]['sale']['id']
|
||||
except:
|
||||
ppid = p.info_data.get('id')
|
||||
payments.append({
|
||||
'PTID': '1',
|
||||
'PTN': 'PayPal',
|
||||
'PTNo1': ppid,
|
||||
'PTNo2': p.info_data.get('id'),
|
||||
'PTNo7': round(float(p.amount), 2),
|
||||
'PTNo8': str(self.event.currency),
|
||||
'PTNo11': paypal_email or '',
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
elif p.provider == 'banktransfer':
|
||||
payments.append({
|
||||
'PTID': '4',
|
||||
'PTN': 'Vorkasse',
|
||||
'PTNo4': p.info_data.get('reference') or p.payment_provider._code(invoice.order),
|
||||
'PTNo7': round(float(p.amount), 2),
|
||||
'PTNo8': str(self.event.currency),
|
||||
'PTNo10': p.info_data.get('payer') or '',
|
||||
'PTNo14': p.info_data.get('date') or '',
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
elif p.provider == 'sepadebit':
|
||||
with language(invoice.order.locale):
|
||||
payments.append({
|
||||
'PTID': '5',
|
||||
'PTN': 'Lastschrift',
|
||||
'PTNo4': ugettext('Event ticket {event}-{code}').format(
|
||||
event=self.event.slug.upper(),
|
||||
code=invoice.order.code
|
||||
),
|
||||
'PTNo5': p.info_data.get('iban') or '',
|
||||
'PTNo6': p.info_data.get('bic') or '',
|
||||
'PTNo7': round(float(p.amount), 2),
|
||||
'PTNo8': str(self.event.currency) or '',
|
||||
'PTNo9': p.info_data.get('date') or '',
|
||||
'PTNo10': p.info_data.get('account') or '',
|
||||
'PTNo14': p.info_data.get('reference') or '',
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
elif p.provider.startswith('stripe'):
|
||||
src = p.info_data.get("source", p.info_data)
|
||||
payments.append({
|
||||
'PTID': '81',
|
||||
'PTN': 'Stripe',
|
||||
'PTNo1': p.info_data.get("id") or '',
|
||||
'PTNo5': src.get("card", {}).get("last4") or '',
|
||||
'PTNo7': round(float(p.amount), 2) or '',
|
||||
'PTNo8': str(self.event.currency) or '',
|
||||
'PTNo10': src.get('owner', {}).get('verified_name') or src.get('owner', {}).get('name') or '',
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
else:
|
||||
payments.append({
|
||||
'PTID': '0',
|
||||
'PTN': p.provider,
|
||||
'PTNo7': round(float(p.amount), 2) or '',
|
||||
'PTNo8': str(self.event.currency) or '',
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
|
||||
payments = [
|
||||
{
|
||||
k: v for k, v in p.items() if v is not None
|
||||
} for p in payments
|
||||
]
|
||||
|
||||
hdr = {
|
||||
'C': str(invoice.invoice_to_country) or self.event.settings.invoice_address_from_country,
|
||||
'CC': self.event.currency,
|
||||
'City': invoice.invoice_to_city,
|
||||
'CN': invoice.invoice_to_company,
|
||||
'DIC': self.event.settings.invoice_address_from_country,
|
||||
# DIC is a little bit unclean, should be the event location's country
|
||||
'DIDt': invoice.order.datetime.isoformat().replace('Z', '+00:00'),
|
||||
'DT': '30' if invoice.is_cancellation else '10',
|
||||
'EM': invoice.order.email,
|
||||
'FamN': invoice.invoice_to_name.rsplit(' ', 1)[-1],
|
||||
'FN': invoice.invoice_to_name.rsplit(' ', 1)[0] if ' ' in invoice.invoice_to_name else '',
|
||||
'IDt': invoice.date.isoformat() + 'T08:00:00+01:00',
|
||||
'INo': invoice.full_invoice_no,
|
||||
'IsNet': invoice.reverse_charge,
|
||||
'ODt': invoice.order.datetime.isoformat().replace('Z', '+00:00'),
|
||||
'OID': invoice.order.code,
|
||||
'SID': self.event.slug,
|
||||
'SN': str(self.event),
|
||||
'Str': invoice.invoice_to_street or '',
|
||||
'TGrossA': round(float(gross_total), 2),
|
||||
'TNetA': round(float(net_total), 2),
|
||||
'TVatA': round(float(gross_total - net_total), 2),
|
||||
'VatDp': False,
|
||||
'Zip': invoice.invoice_to_zipcode
|
||||
}
|
||||
if not hdr['FamN'] and not hdr['CN']:
|
||||
hdr['CN'] = "Unbekannter Kunde"
|
||||
|
||||
if invoice.refers:
|
||||
hdr['PvrINo'] = invoice.refers.full_invoice_no
|
||||
if p_last:
|
||||
hdr['PmDt'] = p_last.payment_date.isoformat().replace('Z', '+00:00')
|
||||
if paypal_email:
|
||||
hdr['PPEm'] = paypal_email
|
||||
if invoice.invoice_to_vat_id:
|
||||
hdr['VatID'] = invoice.invoice_to_vat_id
|
||||
|
||||
return {
|
||||
'IsValid': True,
|
||||
'Hdr': hdr,
|
||||
'InvcPstns': positions,
|
||||
'PmIs': payments,
|
||||
'ValidationMessage': ''
|
||||
}
|
||||
|
||||
def render(self, form_data):
|
||||
qs = self.event.invoices.select_related('order').prefetch_related('lines', 'lines__subevent')
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(date__gte=date_value)
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(date__lte=date_value)
|
||||
|
||||
jo = {
|
||||
'Format': 'NREI',
|
||||
'Version': '18.10.2.0',
|
||||
'SourceSystem': 'pretix',
|
||||
'Data': [
|
||||
self._encode_invoice(i) for i in qs
|
||||
]
|
||||
}
|
||||
return '{}_nrei.json'.format(self.event.slug), 'application/json', json.dumps(jo, cls=DjangoJSONEncoder, indent=4)
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=ugettext_lazy('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=ugettext_lazy('Only include invoices issued on or after this date. Note that the invoice date does '
|
||||
'not always correspond to the order or payment date.')
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=ugettext_lazy('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=ugettext_lazy('Only include invoices issued on or before this date. Note that the invoice date '
|
||||
'does not always correspond to the order or payment date.')
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_dekodi_nrei")
|
||||
def register_dekodi_export(sender, **kwargs):
|
||||
return DekodiNREIExporter
|
||||
@@ -46,6 +46,7 @@ class InvoiceExporter(BaseExporter):
|
||||
qs = qs.filter(date__lte=date_value)
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
any = False
|
||||
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for i in qs:
|
||||
try:
|
||||
@@ -54,14 +55,19 @@ class InvoiceExporter(BaseExporter):
|
||||
i.refresh_from_db()
|
||||
i.file.open('rb')
|
||||
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
|
||||
any = True
|
||||
i.file.close()
|
||||
except FileNotFoundError:
|
||||
invoice_pdf_task.apply(args=(i.pk,))
|
||||
i.refresh_from_db()
|
||||
i.file.open('rb')
|
||||
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
|
||||
any = True
|
||||
i.file.close()
|
||||
|
||||
if not any:
|
||||
return None
|
||||
|
||||
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
|
||||
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from django import forms
|
||||
from django.db.models import DateTimeField, F, Max, OuterRef, Subquery, Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
from django.utils.translation import pgettext, ugettext as _, ugettext_lazy
|
||||
|
||||
from pretix.base.models import (
|
||||
InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
||||
@@ -108,6 +108,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
]
|
||||
|
||||
headers.append(_('Invoice numbers'))
|
||||
headers.append(_('Sales channel'))
|
||||
|
||||
yield headers
|
||||
|
||||
@@ -176,6 +177,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
]
|
||||
|
||||
row.append(', '.join([i.number for i in order.invoices.all()]))
|
||||
row.append(order.sales_channel)
|
||||
yield row
|
||||
|
||||
def iterate_fees(self, form_data: dict):
|
||||
@@ -267,6 +269,10 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
_('Order date'),
|
||||
]
|
||||
if self.event.has_subevents:
|
||||
headers.append(pgettext('subevent', 'Date'))
|
||||
headers += [
|
||||
_('Product'),
|
||||
_('Variation'),
|
||||
_('Price'),
|
||||
@@ -297,6 +303,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
]
|
||||
headers.append(_('Sales channel'))
|
||||
|
||||
yield headers
|
||||
|
||||
@@ -308,6 +315,10 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
]
|
||||
if self.event.has_subevents:
|
||||
row.append(op.subevent)
|
||||
row += [
|
||||
str(op.item),
|
||||
str(op.variation) if op.variation else '',
|
||||
op.price,
|
||||
@@ -351,6 +362,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
row.append(order.sales_channel)
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
|
||||
@@ -9,14 +9,17 @@ import vat_moss.id
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import get_language, ugettext_lazy as _
|
||||
from django_countries import countries
|
||||
from django_countries.fields import Country, CountryField
|
||||
|
||||
from pretix.base.forms.widgets import (
|
||||
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
|
||||
TimePickerWidget, UploadedFileWidget,
|
||||
)
|
||||
from pretix.base.models import InvoiceAddress, Question
|
||||
from pretix.base.models import InvoiceAddress, Question, QuestionOption
|
||||
from pretix.base.models.tax import EU_COUNTRIES
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
@@ -144,6 +147,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
item = pos.item
|
||||
questions = pos.item.questions_to_ask
|
||||
event = kwargs.pop('event')
|
||||
self.all_optional = kwargs.pop('all_optional', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -171,6 +175,8 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial = None
|
||||
tz = pytz.timezone(event.settings.timezone)
|
||||
help_text = rich_text(q.help_text)
|
||||
label = escape(q.question) # django-bootstrap3 calls mark_safe
|
||||
required = q.required and not self.all_optional
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
if q.required:
|
||||
# For some reason, django-bootstrap3 does not set the required attribute
|
||||
@@ -185,71 +191,81 @@ class BaseQuestionsForm(forms.Form):
|
||||
initialbool = False
|
||||
|
||||
field = forms.BooleanField(
|
||||
label=q.question, required=q.required,
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initialbool, widget=widget,
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
label=q.question, required=q.required,
|
||||
label=label, required=required,
|
||||
help_text=q.help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
min_value=Decimal('0.00'),
|
||||
)
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=q.question, required=q.required,
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=q.question, required=q.required,
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_COUNTRYCODE:
|
||||
field = CountryField().formfield(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
empty_label='',
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
label=q.question, required=q.required,
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
to_field_name='identifier',
|
||||
empty_label='',
|
||||
initial=initial.options.first() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=q.options,
|
||||
label=q.question, required=q.required,
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
to_field_name='identifier',
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
initial=initial.options.all() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_FILE:
|
||||
field = forms.FileField(
|
||||
label=q.question, required=q.required,
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
field = forms.DateField(
|
||||
label=q.question, required=q.required,
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
|
||||
widget=DatePickerWidget(),
|
||||
)
|
||||
elif q.type == Question.TYPE_TIME:
|
||||
field = forms.TimeField(
|
||||
label=q.question, required=q.required,
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).time() if initial and initial.answer else None,
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
field = SplitDateTimeField(
|
||||
label=q.question, required=q.required,
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
|
||||
widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
@@ -258,6 +274,15 @@ class BaseQuestionsForm(forms.Form):
|
||||
if answers:
|
||||
# Cache the answer object for later use
|
||||
field.answer = answers[0]
|
||||
|
||||
if q.dependency_question_id:
|
||||
field.widget.attrs['data-question-dependency'] = q.dependency_question_id
|
||||
field.widget.attrs['data-question-dependency-value'] = q.dependency_value
|
||||
if q.type != 'M':
|
||||
field.widget.attrs['required'] = q.required and not self.all_optional
|
||||
field._required = q.required and not self.all_optional
|
||||
field.required = False
|
||||
|
||||
self.fields['question_%s' % q.id] = field
|
||||
|
||||
responses = question_form_fields.send(sender=event, position=pos)
|
||||
@@ -268,6 +293,40 @@ class BaseQuestionsForm(forms.Form):
|
||||
self.fields[key] = value
|
||||
value.initial = data.get('question_form_data', {}).get(key)
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
|
||||
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
|
||||
|
||||
def question_is_visible(parentid, qval):
|
||||
parentq = question_cache[parentid]
|
||||
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_value):
|
||||
return False
|
||||
if 'question_%d' % parentid not in d:
|
||||
return False
|
||||
dval = d.get('question_%d' % parentid)
|
||||
if qval == 'True':
|
||||
return dval
|
||||
elif qval == 'False':
|
||||
return not dval
|
||||
elif isinstance(dval, QuestionOption):
|
||||
return dval.identifier == qval
|
||||
else:
|
||||
return qval in [o.identifier for o in dval]
|
||||
|
||||
def question_is_required(q):
|
||||
return (
|
||||
q.required and
|
||||
(not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_value))
|
||||
)
|
||||
|
||||
if not self.all_optional:
|
||||
for q in question_cache.values():
|
||||
if question_is_required(q) and not d.get('question_%d' % q.pk):
|
||||
raise ValidationError({'question_%d' % q.pk: [_('This field is required')]})
|
||||
|
||||
return d
|
||||
|
||||
|
||||
class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
vat_warning = False
|
||||
@@ -293,6 +352,27 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.request = kwargs.pop('request', None)
|
||||
self.validate_vat_id = kwargs.pop('validate_vat_id')
|
||||
self.all_optional = kwargs.pop('all_optional', False)
|
||||
|
||||
kwargs.setdefault('initial', {})
|
||||
if not kwargs.get('instance') or not kwargs['instance'].country:
|
||||
# Try to guess the initial country from either the country of the merchant
|
||||
# or the locale. This will hopefully save at least some users some scrolling :)
|
||||
locale = get_language()
|
||||
country = event.settings.invoice_address_from_country
|
||||
if not country:
|
||||
valid_countries = countries.countries
|
||||
if '-' in locale:
|
||||
parts = locale.split('-')
|
||||
if parts[1].upper() in valid_countries:
|
||||
country = Country(parts[1].upper())
|
||||
elif parts[0].upper() in valid_countries:
|
||||
country = Country(parts[0].upper())
|
||||
else:
|
||||
if locale in valid_countries:
|
||||
country = Country(locale.upper())
|
||||
|
||||
kwargs['initial']['country'] = country
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
if not event.settings.invoice_address_vatid:
|
||||
del self.fields['vat_id']
|
||||
@@ -344,6 +424,12 @@ 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 len(data.get('name_parts', {})) == 1:
|
||||
# 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 self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass
|
||||
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
|
||||
|
||||
@@ -9,14 +9,17 @@ import vat_moss.exchange_rates
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import pgettext, ugettext
|
||||
from django.utils.translation import (
|
||||
get_language, pgettext, ugettext, ugettext_lazy,
|
||||
)
|
||||
from PIL.Image import BICUBIC
|
||||
from reportlab.lib import pagesizes
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from reportlab.lib.enums import TA_LEFT, TA_RIGHT
|
||||
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.platypus import (
|
||||
@@ -122,6 +125,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
"""
|
||||
stylesheet = StyleSheet1()
|
||||
stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='InvoiceFrom', parent=stylesheet['Normal']))
|
||||
stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2))
|
||||
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10))
|
||||
@@ -138,6 +142,12 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
|
||||
italic='OpenSansIt', boldItalic='OpenSansBI')
|
||||
|
||||
def _upper(self, val):
|
||||
# We uppercase labels, but not in every language
|
||||
if get_language() == 'el':
|
||||
return val
|
||||
return val.upper()
|
||||
|
||||
def _on_other_page(self, canvas: Canvas, doc):
|
||||
"""
|
||||
Called when a new page is rendered that is *not* the first page.
|
||||
@@ -248,52 +258,67 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
canvas.restoreState()
|
||||
|
||||
invoice_to_width = 85 * mm
|
||||
invoice_to_height = 50 * mm
|
||||
invoice_to_left = 25 * mm
|
||||
invoice_to_top = 52 * mm
|
||||
|
||||
def _draw_invoice_to(self, canvas):
|
||||
p = Paragraph(self.invoice.invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p.wrapOn(canvas, 85 * mm, 50 * mm)
|
||||
p_size = p.wrap(85 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
|
||||
p = Paragraph(self.invoice.address_invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
|
||||
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
|
||||
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
|
||||
|
||||
invoice_from_width = 70 * mm
|
||||
invoice_from_height = 50 * mm
|
||||
invoice_from_left = 25 * mm
|
||||
invoice_from_top = 17 * mm
|
||||
|
||||
def _draw_invoice_from(self, canvas):
|
||||
p = Paragraph(self.invoice.full_invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p.wrapOn(canvas, 70 * mm, 50 * mm)
|
||||
p_size = p.wrap(70 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
|
||||
|
||||
def _on_first_page(self, canvas: Canvas, doc):
|
||||
canvas.setCreator('pretix.eu')
|
||||
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
|
||||
|
||||
canvas.saveState()
|
||||
canvas.setFont(self.font_regular, 8)
|
||||
|
||||
if self.invoice.order.testmode:
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSansBd', 30)
|
||||
canvas.setFillColorRGB(32, 0, 0)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, ugettext('TEST MODE'))
|
||||
canvas.restoreState()
|
||||
|
||||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||
p = Paragraph(self.invoice.full_invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet[
|
||||
'InvoiceFrom'])
|
||||
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
|
||||
p_size = p.wrap(self.invoice_from_width, self.invoice_from_height)
|
||||
p.drawOn(canvas, self.invoice_from_left, self.pagesize[1] - p_size[1] - self.invoice_from_top)
|
||||
|
||||
def _draw_invoice_from_label(self, canvas):
|
||||
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
||||
textobject.textLine(self._upper(pgettext('invoice', 'Invoice from')))
|
||||
canvas.drawText(textobject)
|
||||
|
||||
self._draw_invoice_from(canvas)
|
||||
|
||||
def _draw_invoice_to_label(self, canvas):
|
||||
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
||||
textobject.textLine(self._upper(pgettext('invoice', 'Invoice to')))
|
||||
canvas.drawText(textobject)
|
||||
|
||||
self._draw_invoice_to(canvas)
|
||||
logo_width = 25 * mm
|
||||
logo_height = 25 * mm
|
||||
logo_left = 95 * mm
|
||||
logo_top = 13 * mm
|
||||
logo_anchor = 'n'
|
||||
|
||||
def _draw_logo(self, canvas):
|
||||
if self.invoice.event.settings.invoice_logo_image:
|
||||
logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True)
|
||||
ir = ThumbnailingImageReader(logo_file)
|
||||
try:
|
||||
ir.resize(self.logo_width, self.logo_height, 300)
|
||||
except:
|
||||
logger.exception("Can not resize image")
|
||||
pass
|
||||
canvas.drawImage(ir,
|
||||
self.logo_left,
|
||||
self.pagesize[1] - self.logo_height - self.logo_top,
|
||||
width=self.logo_width, height=self.logo_height,
|
||||
preserveAspectRatio=True, anchor=self.logo_anchor,
|
||||
mask='auto')
|
||||
|
||||
def _draw_metadata(self, canvas):
|
||||
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Order code').upper())
|
||||
textobject.textLine(self._upper(pgettext('invoice', 'Order code')))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.order.full_code)
|
||||
@@ -302,18 +327,18 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
if self.invoice.is_cancellation:
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation number').upper())
|
||||
textobject.textLine(self._upper(pgettext('invoice', 'Cancellation number')))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice').upper())
|
||||
textobject.textLine(self._upper(pgettext('invoice', 'Original invoice')))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.refers.number)
|
||||
else:
|
||||
textobject.textLine(pgettext('invoice', 'Invoice number').upper())
|
||||
textobject.textLine(self._upper(pgettext('invoice', 'Invoice number')))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
@@ -321,20 +346,20 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
if self.invoice.is_cancellation:
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation date').upper())
|
||||
textobject.textLine(self._upper(pgettext('invoice', 'Cancellation date')))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice date').upper())
|
||||
textobject.textLine(self._upper(pgettext('invoice', 'Original invoice date')))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(date_format(self.invoice.refers.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
else:
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice date').upper())
|
||||
textobject.textLine(self._upper(pgettext('invoice', 'Invoice date')))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
||||
@@ -342,37 +367,37 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
canvas.drawText(textobject)
|
||||
|
||||
if self.invoice.event.settings.invoice_logo_image:
|
||||
logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True)
|
||||
ir = ThumbnailingImageReader(logo_file)
|
||||
try:
|
||||
ir.resize(25 * mm, 25 * mm, 300)
|
||||
except:
|
||||
logger.exception("Can not resize image")
|
||||
pass
|
||||
canvas.drawImage(ir,
|
||||
95 * mm, (297 - 38) * mm,
|
||||
width=25 * mm, height=25 * mm,
|
||||
preserveAspectRatio=True, anchor='n',
|
||||
mask='auto')
|
||||
event_left = 125 * mm
|
||||
event_top = 17 * mm
|
||||
event_width = 65 * mm
|
||||
event_height = 50 * mm
|
||||
|
||||
def _draw_event_label(self, canvas):
|
||||
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(self._upper(pgettext('invoice', 'Event')))
|
||||
canvas.drawText(textobject)
|
||||
|
||||
def _draw_event(self, canvas):
|
||||
def shorten(txt):
|
||||
txt = str(txt)
|
||||
p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p_size = p.wrap(65 * mm, 50 * mm)
|
||||
p_size = p.wrap(self.event_width, self.event_height)
|
||||
|
||||
while p_size[1] > 2 * self.stylesheet['Normal'].leading:
|
||||
txt = ' '.join(txt.replace('…', '').split()[:-1]) + '…'
|
||||
p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p_size = p.wrap(65 * mm, 50 * mm)
|
||||
p_size = p.wrap(self.event_width, self.event_height)
|
||||
return txt
|
||||
|
||||
if not self.invoice.event.has_subevents:
|
||||
if self.invoice.event.settings.show_date_to and self.invoice.event.date_to:
|
||||
p_str = (
|
||||
shorten(self.invoice.event.name) + '\n' + pgettext('invoice', '{from_date}\nuntil {to_date}').format(
|
||||
shorten(self.invoice.event.name) + '\n' +
|
||||
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
|
||||
from_date=self.invoice.event.get_date_from_display(),
|
||||
to_date=self.invoice.event.get_date_to_display())
|
||||
to_date=self.invoice.event.get_date_to_display()
|
||||
)
|
||||
)
|
||||
else:
|
||||
p_str = (
|
||||
@@ -382,15 +407,38 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
p_str = shorten(self.invoice.event.name)
|
||||
|
||||
p = Paragraph(p_str.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p.wrapOn(canvas, 65 * mm, 50 * mm)
|
||||
p_size = p.wrap(65 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
|
||||
p.wrapOn(canvas, self.event_width, self.event_height)
|
||||
p_size = p.wrap(self.event_width, self.event_height)
|
||||
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
|
||||
self._draw_event_label(canvas)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Event').upper())
|
||||
canvas.drawText(textobject)
|
||||
def _draw_footer(self, canvas):
|
||||
canvas.setFont(self.font_regular, 8)
|
||||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||
|
||||
def _draw_testmode(self, canvas):
|
||||
if self.invoice.order.testmode:
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSansBd', 30)
|
||||
canvas.setFillColorRGB(32, 0, 0)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, ugettext('TEST MODE'))
|
||||
canvas.restoreState()
|
||||
|
||||
def _on_first_page(self, canvas: Canvas, doc):
|
||||
canvas.setCreator('pretix.eu')
|
||||
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
|
||||
|
||||
canvas.saveState()
|
||||
self._draw_footer(canvas)
|
||||
self._draw_testmode(canvas)
|
||||
self._draw_invoice_from_label(canvas)
|
||||
self._draw_invoice_from(canvas)
|
||||
self._draw_invoice_to_label(canvas)
|
||||
self._draw_invoice_to(canvas)
|
||||
self._draw_metadata(canvas)
|
||||
self._draw_logo(canvas)
|
||||
self._draw_event(canvas)
|
||||
canvas.restoreState()
|
||||
|
||||
def _get_first_page_frames(self, doc):
|
||||
@@ -428,6 +476,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.invoice_to_vat_id:
|
||||
story.append(Paragraph(
|
||||
pgettext('invoice', 'Customer VAT ID') + ':<br />' +
|
||||
bleach.clean(self.invoice.invoice_to_vat_id, tags=[]).replace("\n", "<br />\n"),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.invoice_to_beneficiary:
|
||||
story.append(Paragraph(
|
||||
pgettext('invoice', 'Beneficiary') + ':<br />' +
|
||||
@@ -548,6 +603,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
colwidths = [a * doc.width for a in (.25, .15, .15, .15, .3)]
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
story.append(Spacer(5 * mm, 5 * mm))
|
||||
story.append(KeepTogether([
|
||||
Paragraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']),
|
||||
table
|
||||
@@ -601,6 +657,114 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
return story
|
||||
|
||||
|
||||
class Modern1Renderer(ClassicInvoiceRenderer):
|
||||
identifier = 'modern1'
|
||||
verbose_name = ugettext_lazy('Modern Invoice Renderer (pretix 2.7)')
|
||||
bottom_margin = 16.9 * mm
|
||||
top_margin = 16.9 * mm
|
||||
right_margin = 20 * mm
|
||||
invoice_to_height = 27.3 * mm
|
||||
invoice_to_width = 80 * mm
|
||||
invoice_to_left = 25 * mm
|
||||
invoice_to_top = (40 + 17.7) * mm
|
||||
invoice_from_left = 125 * mm
|
||||
invoice_from_top = 50 * mm
|
||||
invoice_from_width = pagesizes.A4[0] - invoice_from_left - right_margin
|
||||
invoice_from_height = 50 * mm
|
||||
|
||||
logo_width = 75 * mm
|
||||
logo_height = 25 * mm
|
||||
logo_left = pagesizes.A4[0] - logo_width - right_margin
|
||||
logo_top = top_margin
|
||||
logo_anchor = 'e'
|
||||
|
||||
event_left = 25 * mm
|
||||
event_top = top_margin
|
||||
event_width = 80 * mm
|
||||
event_height = 25 * mm
|
||||
|
||||
def _get_stylesheet(self):
|
||||
stylesheet = super()._get_stylesheet()
|
||||
stylesheet.add(ParagraphStyle(name='Sender', fontName=self.font_regular, fontSize=8, leading=10))
|
||||
stylesheet['InvoiceFrom'].alignment = TA_RIGHT
|
||||
return stylesheet
|
||||
|
||||
def _draw_invoice_from(self, canvas):
|
||||
if not self.invoice.invoice_from:
|
||||
return
|
||||
c = self.invoice.address_invoice_from.strip().split('\n')
|
||||
p = Paragraph(' · '.join(c), style=self.stylesheet['Sender'])
|
||||
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
|
||||
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
|
||||
super()._draw_invoice_from(canvas)
|
||||
|
||||
def _draw_invoice_to_label(self, canvas):
|
||||
pass
|
||||
|
||||
def _draw_invoice_from_label(self, canvas):
|
||||
pass
|
||||
|
||||
def _draw_event_label(self, canvas):
|
||||
pass
|
||||
|
||||
def _get_first_page_frames(self, doc):
|
||||
footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm
|
||||
return [
|
||||
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 95 * mm,
|
||||
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length,
|
||||
id='normal')
|
||||
]
|
||||
|
||||
def _draw_metadata(self, canvas):
|
||||
begin_top = 100 * mm
|
||||
|
||||
textobject = canvas.beginText(self.left_margin, self.pagesize[1] - begin_top)
|
||||
textobject.setFont(self.font_regular, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Order code'))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.order.full_code)
|
||||
canvas.drawText(textobject)
|
||||
|
||||
if self.invoice.is_cancellation:
|
||||
textobject = canvas.beginText(self.left_margin + 50 * mm, self.pagesize[1] - begin_top)
|
||||
textobject.setFont(self.font_regular, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation number'))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(self.left_margin + 100 * mm, self.pagesize[1] - begin_top)
|
||||
textobject.setFont(self.font_regular, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice'))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.refers.number)
|
||||
canvas.drawText(textobject)
|
||||
else:
|
||||
textobject = canvas.beginText(self.left_margin + 70 * mm, self.pagesize[1] - begin_top)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice number'))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
canvas.drawText(textobject)
|
||||
|
||||
p = Paragraph(date_format(self.invoice.date, "DATE_FORMAT"), style=self.stylesheet['Normal'])
|
||||
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
|
||||
p.wrapOn(canvas, w, 15 * mm)
|
||||
date_x = self.pagesize[0] - w - self.right_margin
|
||||
p.drawOn(canvas, date_x, self.pagesize[1] - begin_top - 10 - 6)
|
||||
|
||||
textobject = canvas.beginText(date_x, self.pagesize[1] - begin_top)
|
||||
textobject.setFont(self.font_regular, 8)
|
||||
if self.invoice.is_cancellation:
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation date'))
|
||||
else:
|
||||
textobject.textLine(pgettext('invoice', 'Invoice date'))
|
||||
canvas.drawText(textobject)
|
||||
|
||||
|
||||
@receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic")
|
||||
def recv_classic(sender, **kwargs):
|
||||
return ClassicInvoiceRenderer
|
||||
return [ClassicInvoiceRenderer, Modern1Renderer]
|
||||
|
||||
51
src/pretix/base/management/commands/makemigrations.py
Normal file
51
src/pretix/base/management/commands/makemigrations.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Django, for theoretically very valid reasons, creates migrations for *every single thing*
|
||||
we change on a model. Even the `help_text`! This makes sense, as we don't know if any
|
||||
database backend unknown to us might actually use this information for its database schema.
|
||||
|
||||
However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
|
||||
certain that some changes to models will never require a change to the database. In this case,
|
||||
not creating a migration for certain changes will save us some performance while applying them
|
||||
*and* allow for a cleaner git history. Win-win!
|
||||
|
||||
Only caveat is that we need to do some dirty monkeypatching to achieve it...
|
||||
"""
|
||||
from django.core.management.commands.makemigrations import Command as Parent
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import models as modelops
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
|
||||
IGNORED_ATTRS = [
|
||||
# (field type, attribute name, blacklist of field sub-types)
|
||||
(models.Field, 'verbose_name', []),
|
||||
(models.Field, 'help_text', []),
|
||||
(models.Field, 'validators', []),
|
||||
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
|
||||
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
|
||||
models.TimeField]),
|
||||
(models.CharField, 'choices', [CountryField])
|
||||
]
|
||||
|
||||
original_deconstruct = models.Field.deconstruct
|
||||
|
||||
|
||||
def new_deconstruct(self):
|
||||
name, path, args, kwargs = original_deconstruct(self)
|
||||
for ftype, attr, blacklist in IGNORED_ATTRS:
|
||||
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in blacklist):
|
||||
kwargs.pop(attr, None)
|
||||
return name, path, args, kwargs
|
||||
|
||||
|
||||
models.Field.deconstruct = new_deconstruct
|
||||
|
||||
|
||||
class Command(Parent):
|
||||
pass
|
||||
28
src/pretix/base/management/commands/migrate.py
Normal file
28
src/pretix/base/management/commands/migrate.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
|
||||
run when there are things we have no migrations for. Usually, this is intended, and running
|
||||
"makemigrations" can really screw up the environment of a user, so we want to prevent novice
|
||||
users from doing that by going really dirty and fitlering it from the output.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from django.core.management.base import OutputWrapper
|
||||
from django.core.management.commands.migrate import Command as Parent
|
||||
|
||||
|
||||
class OutputFilter(OutputWrapper):
|
||||
blacklist = (
|
||||
"Your models have changes that are not yet reflected",
|
||||
"Run 'manage.py makemigrations' to make new "
|
||||
)
|
||||
|
||||
def write(self, msg, style_func=None, ending=None):
|
||||
if any(b in msg for b in self.blacklist):
|
||||
return
|
||||
super().write(msg, style_func, ending)
|
||||
|
||||
|
||||
class Command(Parent):
|
||||
def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False):
|
||||
super().__init__(stdout, stderr, no_color, force_color)
|
||||
self.stdout = OutputFilter(stdout or sys.stdout)
|
||||
@@ -97,7 +97,7 @@ def get_language_from_event(request: HttpRequest) -> str:
|
||||
|
||||
|
||||
def get_language_from_browser(request: HttpRequest) -> str:
|
||||
accept = request.META.get('HTTP_ACCEPT_LANGUAGE', '')
|
||||
accept = request.headers.get('Accept-Language', '')
|
||||
for accept_lang, unused in parse_accept_lang_header(accept):
|
||||
if accept_lang == '*':
|
||||
break
|
||||
|
||||
27
src/pretix/base/migrations/0113_auto_20190312_0942.py
Normal file
27
src/pretix/base/migrations/0113_auto_20190312_0942.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.1.5 on 2019-03-12 09:42
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0112_auto_20190304_1726'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='dependency_question',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_questions', to='pretixbase.Question'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='dependency_value',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
60
src/pretix/base/migrations/0114_auto_20190316_1014.py
Normal file
60
src/pretix/base/migrations/0114_auto_20190316_1014.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 2.1.7 on 2019-03-16 10:14
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0113_auto_20190312_0942'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ItemBundle',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('count', models.PositiveIntegerField(default=1, verbose_name='Number')),
|
||||
('designated_price', models.DecimalField(blank=True, decimal_places=2, help_text="If set, it will be shown that this bundled item is responsible for the given value of the total price. This might be important in cases of mixed taxation, but can be kept blank otherwise. This value will NOT be added to the base item's price.", max_digits=10, null=True, verbose_name='Designated price part')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='is_bundled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='addon_to',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='addons', to='pretixbase.CartPosition'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='addon_to',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='addons', to='pretixbase.OrderPosition'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itembundle',
|
||||
name='base_item',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bundles', to='pretixbase.Item'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itembundle',
|
||||
name='bundled_item',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bundled_with', to='pretixbase.Item', verbose_name='Bundled item'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itembundle',
|
||||
name='bundled_variation',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='bundled_with', to='pretixbase.ItemVariation', verbose_name='Bundled variation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='require_bundling',
|
||||
field=models.BooleanField(default=False, help_text='If this option is set, the product will only be sold as part of bundle products.', verbose_name='Only sell this product as part of a bundle'),
|
||||
),
|
||||
]
|
||||
24
src/pretix/base/migrations/0115_auto_20190323_2238.py
Normal file
24
src/pretix/base/migrations/0115_auto_20190323_2238.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 2.1.7 on 2019-03-23 22:38
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0114_auto_20190316_1014'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='itembundle',
|
||||
name='designated_price',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), help_text="If set, it will be shown that this bundled item is responsible for the given value of the total gross price. This might be important in cases of mixed taxation, but can be kept blank otherwise. This value will NOT be added to the base item's price.", max_digits=10, verbose_name='Designated price part'),
|
||||
),
|
||||
]
|
||||
22
src/pretix/base/migrations/0116_auto_20190402_0722.py
Normal file
22
src/pretix/base/migrations/0116_auto_20190402_0722.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.1.5 on 2019-04-02 07:22
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0115_auto_20190323_2238'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='revoked',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
23
src/pretix/base/migrations/0117_auto_20190418_1149.py
Normal file
23
src/pretix/base/migrations/0117_auto_20190418_1149.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2 on 2019-04-18 11:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0116_auto_20190402_0722'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='original_price',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, help_text='If set, this will be displayed next to '
|
||||
'the current price to show that the '
|
||||
'current price is a discounted one. '
|
||||
'This is just a cosmetic setting and '
|
||||
'will not actually impact pricing.',
|
||||
max_digits=7, null=True, verbose_name='Original price'),
|
||||
),
|
||||
]
|
||||
22
src/pretix/base/migrations/0118_auto_20190423_0839.py
Normal file
22
src/pretix/base/migrations/0118_auto_20190423_0839.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.2 on 2019-04-23 08:39
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0117_auto_20190418_1149'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=True, help_text='If selected, this event will show up publicly on the list of dates for your event.', verbose_name='Show in lists'),
|
||||
),
|
||||
]
|
||||
22
src/pretix/base/migrations/0119_auto_20190509_0654.py
Normal file
22
src/pretix/base/migrations/0119_auto_20190509_0654.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.2 on 2019-05-09 06:54
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0118_auto_20190423_0839'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='hidden',
|
||||
field=models.BooleanField(default=False, help_text='This question will only show up in the backend.', verbose_name='Hidden question'),
|
||||
),
|
||||
]
|
||||
77
src/pretix/base/migrations/0120_auto_20190509_0736.py
Normal file
77
src/pretix/base/migrations/0120_auto_20190509_0736.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# Generated by Django 2.2 on 2019-05-09 07:36
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0119_auto_20190509_0654'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='attendee_name_parts',
|
||||
field=jsonfallback.fields.FallbackJSONField(default=dict),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='voucher',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Voucher'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoiceaddress',
|
||||
name='name_parts',
|
||||
field=jsonfallback.fields.FallbackJSONField(default=dict),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='sales_channels',
|
||||
field=pretix.base.models.fields.MultiStringField(default=['web']),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='sales_channel',
|
||||
field=models.CharField(default='web', max_length=190),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='attendee_name_parts',
|
||||
field=jsonfallback.fields.FallbackJSONField(default=dict),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='voucher',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Voucher'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='staffsessionauditlog',
|
||||
name='method',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='email',
|
||||
field=models.EmailField(db_index=True, max_length=190, null=True, unique=True),
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0121_order_email_known_to_work.py
Normal file
18
src/pretix/base/migrations/0121_order_email_known_to_work.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.1 on 2019-05-15 05:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0120_auto_20190509_0736'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='email_known_to_work',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
20
src/pretix/base/migrations/0122_orderposition_web_secret.py
Normal file
20
src/pretix/base/migrations/0122_orderposition_web_secret.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 2.2.1 on 2019-05-15 13:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.orders
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0121_order_email_known_to_work'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='web_secret',
|
||||
field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_secret, max_length=32),
|
||||
),
|
||||
]
|
||||
@@ -9,8 +9,9 @@ from .event import (
|
||||
)
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
|
||||
Quota, SubEventItem, SubEventItemVariation, itempicture_upload_to,
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question,
|
||||
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
|
||||
itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .notifications import NotificationSetting
|
||||
|
||||
@@ -111,6 +111,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
class Meta:
|
||||
verbose_name = _("User")
|
||||
verbose_name_plural = _("Users")
|
||||
ordering = ('email',)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email = self.email.lower()
|
||||
|
||||
@@ -6,7 +6,9 @@ from django.db import models
|
||||
from django.db.models.constants import LOOKUP_SEP
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from pretix.helpers.json import CustomJSONEncoder
|
||||
|
||||
@@ -113,6 +115,40 @@ class LoggedModel(models.Model, LoggingMixin):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@cached_property
|
||||
def logs_content_type(self):
|
||||
return ContentType.objects.get_for_model(type(self))
|
||||
|
||||
@cached_property
|
||||
def all_logentries_link(self):
|
||||
from pretix.base.models import Event
|
||||
|
||||
if isinstance(self, Event):
|
||||
event = self
|
||||
elif hasattr(self, 'event'):
|
||||
event = self.event
|
||||
else:
|
||||
return None
|
||||
return reverse(
|
||||
'control:event.log',
|
||||
kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
}
|
||||
) + '?content_type={}&object={}'.format(
|
||||
self.logs_content_type.pk,
|
||||
self.pk
|
||||
)
|
||||
|
||||
def top_logentries(self):
|
||||
qs = self.all_logentries()
|
||||
if self.all_logentries_link:
|
||||
qs = qs[:25]
|
||||
return qs
|
||||
|
||||
def top_logentries_has_more(self):
|
||||
return self.all_logentries().count() > 25
|
||||
|
||||
def all_logentries(self):
|
||||
"""
|
||||
Returns all log entries that are attached to this object.
|
||||
@@ -122,7 +158,7 @@ class LoggedModel(models.Model, LoggingMixin):
|
||||
from .log import LogEntry
|
||||
|
||||
return LogEntry.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
|
||||
content_type=self.logs_content_type, object_id=self.pk
|
||||
).select_related('user', 'event', 'oauth_application', 'api_token', 'device')
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class Device(LoggedModel):
|
||||
api_token = models.CharField(max_length=190, unique=True, null=True)
|
||||
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
|
||||
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
|
||||
revoked = models.BooleanField(default=False)
|
||||
name = models.CharField(
|
||||
max_length=190,
|
||||
verbose_name=_('Name')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import string
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, time
|
||||
from datetime import datetime, time, timedelta
|
||||
from operator import attrgetter
|
||||
|
||||
import pytz
|
||||
@@ -164,7 +164,7 @@ class EventMixin:
|
||||
def annotated(cls, qs, channel='web'):
|
||||
from pretix.base.models import Item, ItemVariation, Quota
|
||||
|
||||
sq_active_item = Item.objects.filter_available(channel=channel).filter(
|
||||
sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel).filter(
|
||||
Q(variations__isnull=True)
|
||||
& Q(quotas__pk=OuterRef('pk'))
|
||||
).order_by().values_list('quotas__pk').annotate(
|
||||
@@ -186,7 +186,7 @@ class EventMixin:
|
||||
Prefetch(
|
||||
'quotas',
|
||||
to_attr='active_quotas',
|
||||
queryset=Quota.objects.annotate(
|
||||
queryset=Quota.objects.using(settings.DATABASE_REPLICA).annotate(
|
||||
active_items=Subquery(sq_active_item, output_field=models.TextField()),
|
||||
active_variations=Subquery(sq_active_variation, output_field=models.TextField()),
|
||||
).exclude(
|
||||
@@ -445,6 +445,7 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
self.plugins = other.plugins
|
||||
self.is_public = other.is_public
|
||||
self.testmode = other.testmode
|
||||
self.save()
|
||||
|
||||
tax_map = {}
|
||||
@@ -515,6 +516,10 @@ class Event(EventMixin, LoggedModel):
|
||||
o.question = q
|
||||
o.save()
|
||||
|
||||
for q in self.questions.filter(dependency_question__isnull=False):
|
||||
q.dependency_question = question_map[q.dependency_question_id]
|
||||
q.save(update_fields=['dependency_question'])
|
||||
|
||||
for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related('limit_products'):
|
||||
items = list(cl.limit_products.all())
|
||||
cl.pk = None
|
||||
@@ -635,22 +640,6 @@ class Event(EventMixin, LoggedModel):
|
||||
irs = self.get_invoice_renderers()
|
||||
return irs[self.settings.invoice_renderer]
|
||||
|
||||
@property
|
||||
def active_subevents(self):
|
||||
"""
|
||||
Returns a queryset of active subevents.
|
||||
"""
|
||||
return self.subevents.filter(active=True).order_by('-date_from', 'name')
|
||||
|
||||
@property
|
||||
def active_future_subevents(self):
|
||||
return self.subevents.filter(
|
||||
Q(active=True) & (
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(date_to__gte=now())
|
||||
)
|
||||
).order_by('date_from', 'name')
|
||||
|
||||
def subevents_annotated(self, channel):
|
||||
return SubEvent.annotated(self.subevents, channel)
|
||||
|
||||
@@ -663,9 +652,9 @@ class Event(EventMixin, LoggedModel):
|
||||
'name_descending': ('-name', 'date_from'),
|
||||
}[ordering]
|
||||
subevs = queryset.filter(
|
||||
Q(active=True) & (
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(date_to__gte=now())
|
||||
Q(active=True) & Q(is_public=True) & (
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now() - timedelta(hours=24)))
|
||||
| Q(date_to__gte=now() - timedelta(hours=24))
|
||||
)
|
||||
) # order_by doesn't make sense with I18nField
|
||||
for f in reversed(orderfields):
|
||||
@@ -755,6 +744,7 @@ class Event(EventMixin, LoggedModel):
|
||||
return not self.orders.exists() and not self.invoices.exists()
|
||||
|
||||
def delete_sub_objects(self):
|
||||
self.cartposition_set.filter(addon_to__isnull=False).delete()
|
||||
self.cartposition_set.all().delete()
|
||||
self.items.all().delete()
|
||||
self.subevents.all().delete()
|
||||
@@ -828,6 +818,8 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
:type event: Event
|
||||
:param active: Whether to show the subevent
|
||||
:type active: bool
|
||||
:param is_public: Whether to show the subevent in lists
|
||||
:type is_public: bool
|
||||
:param name: This event's full title
|
||||
:type name: str
|
||||
:param date_from: The datetime this event starts
|
||||
@@ -846,6 +838,10 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
active = models.BooleanField(default=False, verbose_name=_("Active"),
|
||||
help_text=_("Only with this checkbox enabled, this date is visible in the "
|
||||
"frontend to users."))
|
||||
is_public = models.BooleanField(default=True,
|
||||
verbose_name=_("Show in lists"),
|
||||
help_text=_("If selected, this event will show up publicly on the list of dates "
|
||||
"for your event."))
|
||||
name = I18nCharField(
|
||||
max_length=200,
|
||||
verbose_name=_("Name"),
|
||||
@@ -932,6 +928,18 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
if self.event:
|
||||
self.event.cache.clear()
|
||||
|
||||
@staticmethod
|
||||
def clean_items(event, items):
|
||||
for item in items:
|
||||
if event != item.event:
|
||||
raise ValidationError(_('One or more items do not belong to this event.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_variations(event, variations):
|
||||
for variation in variations:
|
||||
if event != variation.item.event:
|
||||
raise ValidationError(_('One or more variations do not belong to this event.'))
|
||||
|
||||
|
||||
def generate_invite_token():
|
||||
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
|
||||
@@ -37,7 +37,7 @@ class MultiStringField(TextField):
|
||||
def get_prep_lookup(self, lookup_type, value): # NOQA
|
||||
raise TypeError('Lookups on multi strings are currently not supported.')
|
||||
|
||||
def from_db_value(self, value, expression, connection, context):
|
||||
def from_db_value(self, value, expression, connection):
|
||||
if value:
|
||||
return [v for v in value.split(DELIMITER) if v]
|
||||
else:
|
||||
|
||||
@@ -117,12 +117,35 @@ class Invoice(models.Model):
|
||||
self.invoice_from_name,
|
||||
self.invoice_from,
|
||||
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
||||
str(self.invoice_from_country),
|
||||
self.invoice_from_country.name if self.invoice_from_country else "",
|
||||
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
|
||||
pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id if self.invoice_from_tax_id else "",
|
||||
]
|
||||
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
||||
|
||||
@property
|
||||
def address_invoice_from(self):
|
||||
parts = [
|
||||
self.invoice_from_name,
|
||||
self.invoice_from,
|
||||
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
||||
self.invoice_from_country.name if self.invoice_from_country else "",
|
||||
]
|
||||
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
||||
|
||||
@property
|
||||
def address_invoice_to(self):
|
||||
if self.invoice_to and not self.invoice_to_company and not self.invoice_to_name:
|
||||
return self.invoice_to
|
||||
parts = [
|
||||
self.invoice_to_company,
|
||||
self.invoice_to_name,
|
||||
self.invoice_to_street,
|
||||
(self.invoice_to_zipcode or "") + " " + (self.invoice_to_city or ""),
|
||||
self.invoice_to_country.name if self.invoice_to_country else "",
|
||||
]
|
||||
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
||||
|
||||
def _get_numeric_invoice_number(self):
|
||||
numeric_invoices = Invoice.objects.filter(
|
||||
event__organizer=self.event.organizer,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import sys
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal, DecimalException
|
||||
from typing import Tuple
|
||||
@@ -15,6 +16,7 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import is_naive, make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_countries.fields import Country
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.models import fields
|
||||
@@ -161,7 +163,7 @@ class ItemQuerySet(models.QuerySet):
|
||||
Q(active=True)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(sales_channels__contains=channel)
|
||||
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
|
||||
)
|
||||
if not allow_addons:
|
||||
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
|
||||
@@ -328,6 +330,13 @@ class Item(LoggedModel):
|
||||
help_text=_('This product will be hidden from the event page until the user enters a voucher '
|
||||
'code that is specifically tied to this product (and not via a quota).')
|
||||
)
|
||||
require_bundling = models.BooleanField(
|
||||
verbose_name=_('Only sell this product as part of a bundle'),
|
||||
default=False,
|
||||
help_text=_('If this option is set, the product will only be sold as part of bundle products. Do '
|
||||
'<strong>not</strong> check this option if you want to use this product as an add-on product, '
|
||||
'but only for fixed bundles!')
|
||||
)
|
||||
allow_cancel = models.BooleanField(
|
||||
verbose_name=_('Allow product to be canceled'),
|
||||
default=True,
|
||||
@@ -386,12 +395,28 @@ class Item(LoggedModel):
|
||||
if self.event:
|
||||
self.event.cache.clear()
|
||||
|
||||
def tax(self, price=None, base_price_is='auto'):
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
|
||||
price = price if price is not None else self.default_price
|
||||
|
||||
if not self.tax_rule:
|
||||
return TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
|
||||
rate=Decimal('0.00'), name='')
|
||||
return self.tax_rule.tax(price, base_price_is=base_price_is)
|
||||
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
|
||||
rate=Decimal('0.00'), name='')
|
||||
else:
|
||||
t = self.tax_rule.tax(price, base_price_is=base_price_is, currency=currency)
|
||||
|
||||
if include_bundled:
|
||||
for b in self.bundles.all():
|
||||
if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id:
|
||||
if b.bundled_variation:
|
||||
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
else:
|
||||
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
compare_price = self.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
t.net += bprice.net - compare_price.net
|
||||
t.tax += bprice.tax - compare_price.tax
|
||||
t.name = "MIXED!"
|
||||
|
||||
return t
|
||||
|
||||
def is_available_by_time(self, now_dt: datetime=None) -> bool:
|
||||
now_dt = now_dt or now()
|
||||
@@ -411,7 +436,18 @@ class Item(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None):
|
||||
def _get_quotas(self, ignored_quotas=None, subevent=None):
|
||||
check_quotas = set(getattr(
|
||||
self, '_subevent_quotas', # Utilize cache in product list
|
||||
self.quotas.filter(subevent=subevent).select_related('subevent')
|
||||
if subevent else self.quotas.all()
|
||||
))
|
||||
if ignored_quotas:
|
||||
check_quotas -= set(ignored_quotas)
|
||||
return check_quotas
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None,
|
||||
include_bundled=False, trust_parameters=False):
|
||||
"""
|
||||
This method is used to determine whether this Item is currently available
|
||||
for sale.
|
||||
@@ -420,33 +456,60 @@ class Item(LoggedModel):
|
||||
quotas will be ignored in the calculation. If this leads
|
||||
to no quotas being checked at all, this method will return
|
||||
unlimited availability.
|
||||
:param include_bundled: Also take availability of bundled items into consideration.
|
||||
:param trust_parameters: Disable checking of the subevent parameter and disable checking if
|
||||
any variations exist (performance optimization).
|
||||
:returns: any of the return codes of :py:meth:`Quota.availability()`.
|
||||
|
||||
:raises ValueError: if you call this on an item which has variations associated with it.
|
||||
Please use the method on the ItemVariation object you are interested in.
|
||||
"""
|
||||
check_quotas = set(getattr(
|
||||
self, '_subevent_quotas', # Utilize cache in product list
|
||||
self.quotas.select_related('subevent').filter(subevent=subevent)
|
||||
if subevent else self.quotas.all()
|
||||
))
|
||||
if not subevent and self.event.has_subevents:
|
||||
if not trust_parameters and not subevent and self.event.has_subevents:
|
||||
raise TypeError('You need to supply a subevent.')
|
||||
if ignored_quotas:
|
||||
check_quotas -= set(ignored_quotas)
|
||||
if not check_quotas:
|
||||
return Quota.AVAILABILITY_OK, sys.maxsize
|
||||
if self.has_variations: # NOQA
|
||||
raise ValueError('Do not call this directly on items which have variations '
|
||||
'but call this on their ItemVariation objects')
|
||||
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
|
||||
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
|
||||
check_quotas = self._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent)
|
||||
quotacounter = Counter()
|
||||
res = Quota.AVAILABILITY_OK, None
|
||||
for q in check_quotas:
|
||||
quotacounter[q] += 1
|
||||
|
||||
if include_bundled:
|
||||
for b in self.bundles.all():
|
||||
bundled_check_quotas = (b.bundled_variation or b.bundled_item)._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent)
|
||||
if not bundled_check_quotas:
|
||||
return Quota.AVAILABILITY_GONE, 0
|
||||
for q in bundled_check_quotas:
|
||||
quotacounter[q] += b.count
|
||||
|
||||
for q, n in quotacounter.items():
|
||||
a = q.availability(count_waitinglist=count_waitinglist, _cache=_cache)
|
||||
if a[1] is None:
|
||||
continue
|
||||
|
||||
num_avail = a[1] // n
|
||||
code_avail = Quota.AVAILABILITY_GONE if a[1] >= 1 and num_avail < 1 else a[0]
|
||||
# this is not entirely accurate, as it shows "sold out" even if it is actually just "reserved",
|
||||
# since we do not know that distinction here if at least one item is available. However, this
|
||||
# is only relevant in connection with bundles.
|
||||
|
||||
if code_avail < res[0] or res[1] is None or num_avail < res[1]:
|
||||
res = (code_avail, num_avail)
|
||||
|
||||
if len(quotacounter) == 0:
|
||||
return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility
|
||||
return res
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import OrderPosition
|
||||
|
||||
return not OrderPosition.all.filter(item=self).exists()
|
||||
|
||||
@property
|
||||
def includes_mixed_tax_rate(self):
|
||||
for b in self.bundles.all():
|
||||
if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
def has_variations(self):
|
||||
return self.variations.exists()
|
||||
@@ -490,6 +553,8 @@ class ItemVariation(models.Model):
|
||||
:type active: bool
|
||||
:param default_price: This variation's default price
|
||||
:type default_price: decimal.Decimal
|
||||
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
|
||||
:type original_price: decimal.Decimal
|
||||
"""
|
||||
item = models.ForeignKey(
|
||||
Item,
|
||||
@@ -518,6 +583,13 @@ class ItemVariation(models.Model):
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Default price"),
|
||||
)
|
||||
original_price = models.DecimalField(
|
||||
verbose_name=_('Original price'),
|
||||
blank=True, null=True,
|
||||
max_digits=7, decimal_places=2,
|
||||
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
|
||||
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Product variation")
|
||||
@@ -531,11 +603,28 @@ class ItemVariation(models.Model):
|
||||
def price(self):
|
||||
return self.default_price if self.default_price is not None else self.item.default_price
|
||||
|
||||
def tax(self, price=None):
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
|
||||
price = price if price is not None else self.price
|
||||
|
||||
if not self.item.tax_rule:
|
||||
return TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='')
|
||||
return self.item.tax_rule.tax(price)
|
||||
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
|
||||
rate=Decimal('0.00'), name='')
|
||||
else:
|
||||
t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency)
|
||||
|
||||
if include_bundled:
|
||||
for b in self.item.bundles.all():
|
||||
if b.designated_price and b.bundled_item.tax_rule_id != self.item.tax_rule_id:
|
||||
if b.bundled_variation:
|
||||
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
else:
|
||||
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
compare_price = self.item.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
t.net += bprice.net - compare_price.net
|
||||
t.tax += bprice.tax - compare_price.tax
|
||||
t.name = "MIXED!"
|
||||
|
||||
return t
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
@@ -547,7 +636,18 @@ class ItemVariation(models.Model):
|
||||
if self.item:
|
||||
self.item.event.cache.clear()
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None) -> Tuple[int, int]:
|
||||
def _get_quotas(self, ignored_quotas=None, subevent=None):
|
||||
check_quotas = set(getattr(
|
||||
self, '_subevent_quotas', # Utilize cache in product list
|
||||
self.quotas.filter(subevent=subevent).select_related('subevent')
|
||||
if subevent else self.quotas.all()
|
||||
))
|
||||
if ignored_quotas:
|
||||
check_quotas -= set(ignored_quotas)
|
||||
return check_quotas
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None,
|
||||
include_bundled=False, trust_parameters=False) -> Tuple[int, int]:
|
||||
"""
|
||||
This method is used to determine whether this ItemVariation is currently
|
||||
available for sale in terms of quotas.
|
||||
@@ -559,19 +659,38 @@ class ItemVariation(models.Model):
|
||||
:param count_waitinglist: If ``False``, waiting list entries will be ignored for quota calculation.
|
||||
:returns: any of the return codes of :py:meth:`Quota.availability()`.
|
||||
"""
|
||||
check_quotas = set(getattr(
|
||||
self, '_subevent_quotas', # Utilize cache in product list
|
||||
self.quotas.filter(subevent=subevent).select_related('subevent')
|
||||
if subevent else self.quotas.all()
|
||||
))
|
||||
if ignored_quotas:
|
||||
check_quotas -= set(ignored_quotas)
|
||||
if not subevent and self.item.event.has_subevents: # NOQA
|
||||
if not trust_parameters and not subevent and self.item.event.has_subevents: # NOQA
|
||||
raise TypeError('You need to supply a subevent.')
|
||||
if not check_quotas:
|
||||
return Quota.AVAILABILITY_OK, sys.maxsize
|
||||
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
|
||||
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
|
||||
check_quotas = self._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent)
|
||||
quotacounter = Counter()
|
||||
res = Quota.AVAILABILITY_OK, None
|
||||
for q in check_quotas:
|
||||
quotacounter[q] += 1
|
||||
|
||||
if include_bundled:
|
||||
for b in self.item.bundles.all():
|
||||
bundled_check_quotas = (b.bundled_variation or b.bundled_item)._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent)
|
||||
if not bundled_check_quotas:
|
||||
return Quota.AVAILABILITY_GONE, 0
|
||||
for q in bundled_check_quotas:
|
||||
quotacounter[q] += b.count
|
||||
|
||||
for q, n in quotacounter.items():
|
||||
a = q.availability(count_waitinglist=count_waitinglist, _cache=_cache)
|
||||
if a[1] is None:
|
||||
continue
|
||||
|
||||
num_avail = a[1] // n
|
||||
code_avail = Quota.AVAILABILITY_GONE if a[1] >= 1 and num_avail < 1 else a[0]
|
||||
# this is not entirely accurate, as it shows "sold out" even if it is actually just "reserved",
|
||||
# since we do not know that distinction here if at least one item is available. However, this
|
||||
# is only relevant in connection with bundles.
|
||||
|
||||
if code_avail < res[0] or res[1] is None or num_avail < res[1]:
|
||||
res = (code_avail, num_avail)
|
||||
if len(quotacounter) == 0:
|
||||
return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility
|
||||
return res
|
||||
|
||||
def __lt__(self, other):
|
||||
if self.position == other.position:
|
||||
@@ -672,6 +791,83 @@ class ItemAddOn(models.Model):
|
||||
raise ValidationError(_('The maximum count needs to be greater than the minimum count.'))
|
||||
|
||||
|
||||
class ItemBundle(models.Model):
|
||||
"""
|
||||
An instance of this model indicates that buying a ticket of the type ``base_item``
|
||||
automatically also buys ``count`` items of type ``bundled_item``.
|
||||
|
||||
:param base_item: The base item the bundle is attached to
|
||||
:type base_item: Item
|
||||
:param bundled_item: The bundled item
|
||||
:type bundled_item: Item
|
||||
:param bundled_variation: The variation, if the bundled item has variations
|
||||
:type bundled_variation: ItemVariation
|
||||
:param count: The number of items to bundle
|
||||
:type count: int
|
||||
:param designated_price: The designated part price (optional)
|
||||
:type designated_price: bool
|
||||
"""
|
||||
base_item = models.ForeignKey(
|
||||
Item,
|
||||
related_name='bundles',
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
bundled_item = models.ForeignKey(
|
||||
Item,
|
||||
related_name='bundled_with',
|
||||
verbose_name=_('Bundled item'),
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
bundled_variation = models.ForeignKey(
|
||||
ItemVariation,
|
||||
related_name='bundled_with',
|
||||
verbose_name=_('Bundled variation'),
|
||||
null=True, blank=True,
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
count = models.PositiveIntegerField(
|
||||
default=1,
|
||||
verbose_name=_('Number')
|
||||
)
|
||||
designated_price = models.DecimalField(
|
||||
default=Decimal('0.00'), blank=True,
|
||||
decimal_places=2, max_digits=10,
|
||||
verbose_name=_('Designated price part'),
|
||||
help_text=_('If set, it will be shown that this bundled item is responsible for the given value of the total '
|
||||
'gross price. This might be important in cases of mixed taxation, but can be kept blank otherwise. This '
|
||||
'value will NOT be added to the base item\'s price.')
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
self.clean_count(self.count)
|
||||
|
||||
def describe(self):
|
||||
if self.count == 1:
|
||||
if self.bundled_variation_id:
|
||||
return "{} – {}".format(self.bundled_item.name, self.bundled_variation.value)
|
||||
else:
|
||||
return self.bundled_item.name
|
||||
else:
|
||||
if self.bundled_variation_id:
|
||||
return "{}× {} – {}".format(self.count, self.bundled_item.name, self.bundled_variation.value)
|
||||
else:
|
||||
return "{}x {}".format(self.count, self.bundled_item.name)
|
||||
|
||||
@staticmethod
|
||||
def clean_itemvar(event, bundled_item, bundled_variation):
|
||||
if event != bundled_item.event:
|
||||
raise ValidationError(_('The bundled item must belong to the same event as the item.'))
|
||||
if bundled_item.has_variations and not bundled_variation:
|
||||
raise ValidationError(_('A variation needs to be set for this item.'))
|
||||
if bundled_variation and bundled_variation.item != bundled_item:
|
||||
raise ValidationError(_('The chosen variation does not belong to this item.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_count(count):
|
||||
if count < 0:
|
||||
raise ValidationError(_('The count needs to be equal to or greater than zero.'))
|
||||
|
||||
|
||||
class Question(LoggedModel):
|
||||
"""
|
||||
A question is an input field that can be used to extend a ticket by custom information,
|
||||
@@ -700,8 +896,14 @@ class Question(LoggedModel):
|
||||
:param items: A set of ``Items`` objects that this question should be applied to
|
||||
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
|
||||
:type ask_during_checkin: bool
|
||||
:param hidden: Whether to only show the question in the backend
|
||||
:type hidden: bool
|
||||
:param identifier: An arbitrary, internal identifier
|
||||
:type identifier: str
|
||||
:param dependency_question: This question will only show up if the referenced question is set to `dependency_value`.
|
||||
:type dependency_question: Question
|
||||
:param dependency_value: The value that `dependency_question` needs to be set to for this question to be applicable.
|
||||
:type dependency_value: str
|
||||
"""
|
||||
TYPE_NUMBER = "N"
|
||||
TYPE_STRING = "S"
|
||||
@@ -713,6 +915,7 @@ class Question(LoggedModel):
|
||||
TYPE_DATE = "D"
|
||||
TYPE_TIME = "H"
|
||||
TYPE_DATETIME = "W"
|
||||
TYPE_COUNTRYCODE = "CC"
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_NUMBER, _("Number")),
|
||||
(TYPE_STRING, _("Text (one line)")),
|
||||
@@ -724,6 +927,7 @@ class Question(LoggedModel):
|
||||
(TYPE_DATE, _("Date")),
|
||||
(TYPE_TIME, _("Time")),
|
||||
(TYPE_DATETIME, _("Date and time")),
|
||||
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
|
||||
)
|
||||
|
||||
event = models.ForeignKey(
|
||||
@@ -771,6 +975,15 @@ class Question(LoggedModel):
|
||||
'pretixdesk 0.2 or newer.'),
|
||||
default=False
|
||||
)
|
||||
hidden = models.BooleanField(
|
||||
verbose_name=_('Hidden question'),
|
||||
help_text=_('This question will only show up in the backend.'),
|
||||
default=False
|
||||
)
|
||||
dependency_question = models.ForeignKey(
|
||||
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||
)
|
||||
dependency_value = models.TextField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Question")
|
||||
@@ -870,6 +1083,12 @@ class Question(LoggedModel):
|
||||
return dt
|
||||
except:
|
||||
raise ValidationError(_('Invalid datetime input.'))
|
||||
elif self.type == Question.TYPE_COUNTRYCODE and answer:
|
||||
c = Country(answer.upper())
|
||||
if c.name:
|
||||
return answer
|
||||
else:
|
||||
raise ValidationError(_('Unknown country code.'))
|
||||
|
||||
return answer
|
||||
|
||||
@@ -1089,7 +1308,8 @@ class Quota(LoggedModel):
|
||||
'cached_availability_state', 'cached_availability_number', 'cached_availability_time',
|
||||
'cached_availability_paid_orders'
|
||||
],
|
||||
clear_cache=False
|
||||
clear_cache=False,
|
||||
using='default'
|
||||
)
|
||||
|
||||
if _cache is not None:
|
||||
@@ -1199,7 +1419,7 @@ class Quota(LoggedModel):
|
||||
|
||||
@staticmethod
|
||||
def clean_variations(items, variations):
|
||||
for variation in variations:
|
||||
for variation in (variations or []):
|
||||
if variation.item not in items:
|
||||
raise ValidationError(_('All variations must belong to an item contained in the items list.'))
|
||||
break
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -24,7 +25,7 @@ from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
from django_countries.fields import Country, CountryField
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
@@ -32,6 +33,7 @@ from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.locking import NoLockManager
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from .base import LockModel, LoggedModel
|
||||
@@ -179,6 +181,10 @@ class Order(LockModel, LoggedModel):
|
||||
default=False
|
||||
)
|
||||
sales_channel = models.CharField(max_length=190, default="web")
|
||||
email_known_to_work = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('E-mail address verified')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Order")
|
||||
@@ -205,6 +211,9 @@ class Order(LockModel, LoggedModel):
|
||||
self.event.cache.delete('complain_testmode_orders')
|
||||
self.delete()
|
||||
|
||||
def email_confirm_hash(self):
|
||||
return hashlib.sha256(settings.SECRET_KEY.encode() + self.secret.encode()).hexdigest()[:9]
|
||||
|
||||
@property
|
||||
def fees(self):
|
||||
"""
|
||||
@@ -606,7 +615,7 @@ class Order(LockModel, LoggedModel):
|
||||
), tz)
|
||||
return term_last
|
||||
|
||||
def _can_be_paid(self, count_waitinglist=True) -> Union[bool, str]:
|
||||
def _can_be_paid(self, count_waitinglist=True, ignore_date=False) -> Union[bool, str]:
|
||||
error_messages = {
|
||||
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
|
||||
"payment settings is over."),
|
||||
@@ -617,13 +626,13 @@ class Order(LockModel, LoggedModel):
|
||||
if self.require_approval:
|
||||
return error_messages['require_approval']
|
||||
term_last = self.payment_term_last
|
||||
if term_last:
|
||||
if term_last and not ignore_date:
|
||||
if now() > term_last:
|
||||
return error_messages['late_lastdate']
|
||||
|
||||
if self.status == self.STATUS_PENDING:
|
||||
return True
|
||||
if not self.event.settings.get('payment_term_accept_late'):
|
||||
if not self.event.settings.get('payment_term_accept_late') and not ignore_date:
|
||||
return error_messages['late']
|
||||
|
||||
return self._is_still_available(count_waitinglist=count_waitinglist)
|
||||
@@ -664,7 +673,7 @@ class Order(LockModel, LoggedModel):
|
||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
auth=None, attach_tickets=False):
|
||||
auth=None, attach_tickets=False, position: 'OrderPosition'=None):
|
||||
"""
|
||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||
|
||||
@@ -681,6 +690,9 @@ class Order(LockModel, LoggedModel):
|
||||
:param headers: Dictionary with additional mail headers
|
||||
:param sender: Custom email sender.
|
||||
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
||||
:param position: An order position this refers to. If given, no invoices will be attached, the tickets will
|
||||
only be attached for this position and child positions, the link will only point to the
|
||||
position and the attendee email will be used if available.
|
||||
"""
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
|
||||
@@ -692,12 +704,16 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
with language(self.locale):
|
||||
recipient = self.email
|
||||
if position and position.attendee_email:
|
||||
recipient = position.attendee_email
|
||||
|
||||
try:
|
||||
email_content = render_mail(template, context)
|
||||
mail(
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers, sender,
|
||||
invoices=invoices, attach_tickets=attach_tickets
|
||||
self.event, self.locale, self, headers=headers, sender=sender,
|
||||
invoices=invoices, attach_tickets=attach_tickets,
|
||||
position=position
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
@@ -709,12 +725,41 @@ class Order(LockModel, LoggedModel):
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'position': position.positionid if position else None,
|
||||
'recipient': recipient,
|
||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||
'attach_tickets': attach_tickets,
|
||||
}
|
||||
)
|
||||
|
||||
def resend_link(self, user=None, auth=None):
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
with language(self.locale):
|
||||
try:
|
||||
invoice_name = self.invoice_address.name
|
||||
invoice_company = self.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_template = self.event.settings.mail_text_resend_link
|
||||
email_context = {
|
||||
'event': self.event.name,
|
||||
'url': build_absolute_uri(self.event, 'presale:event.order.open', kwargs={
|
||||
'order': self.code,
|
||||
'secret': self.secret,
|
||||
'hash': self.email_confirm_hash()
|
||||
}),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
email_subject = _('Your order: %(code)s') % {'code': self.code}
|
||||
self.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.resend', user=user, auth=auth,
|
||||
attach_tickets=True
|
||||
)
|
||||
|
||||
@property
|
||||
def positions_with_tickets(self):
|
||||
for op in self.positions.all():
|
||||
@@ -832,6 +877,8 @@ class QuestionAnswer(models.Model):
|
||||
return date_format(d, "TIME_FORMAT")
|
||||
except ValueError:
|
||||
return self.answer
|
||||
elif self.question.type == Question.TYPE_COUNTRYCODE and self.answer:
|
||||
return Country(self.answer).name or self.answer
|
||||
else:
|
||||
return self.answer
|
||||
|
||||
@@ -931,6 +978,10 @@ class AbstractPosition(models.Model):
|
||||
else:
|
||||
return {}
|
||||
|
||||
@meta_info_data.setter
|
||||
def meta_info_data(self, d):
|
||||
self.meta_info = json.dumps(d)
|
||||
|
||||
def cache_answers(self, all=True):
|
||||
"""
|
||||
Creates two properties on the object.
|
||||
@@ -946,16 +997,39 @@ class AbstractPosition(models.Model):
|
||||
# selected via prefetch_related
|
||||
if not all:
|
||||
if hasattr(self.item, 'questions_to_ask'):
|
||||
self.questions = list(copy.copy(q) for q in self.item.questions_to_ask)
|
||||
questions = list(copy.copy(q) for q in self.item.questions_to_ask)
|
||||
else:
|
||||
self.questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False))
|
||||
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False,
|
||||
hidden=False))
|
||||
else:
|
||||
self.questions = list(copy.copy(q) for q in self.item.questions.all())
|
||||
for q in self.questions:
|
||||
questions = list(copy.copy(q) for q in self.item.questions.all())
|
||||
|
||||
question_cache = {
|
||||
q.pk: q for q in questions
|
||||
}
|
||||
|
||||
def question_is_visible(parentid, qval):
|
||||
parentq = question_cache[parentid]
|
||||
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_value):
|
||||
return False
|
||||
if parentid not in self.answ:
|
||||
return False
|
||||
if qval == 'True':
|
||||
return self.answ[parentid].answer == 'True'
|
||||
elif qval == 'False':
|
||||
return self.answ[parentid].answer == 'False'
|
||||
else:
|
||||
return qval in [o.identifier for o in self.answ[parentid].options.all()]
|
||||
|
||||
self.questions = []
|
||||
for q in questions:
|
||||
if q.id in self.answ:
|
||||
q.answer = self.answ[q.id]
|
||||
q.answer.question = q # cache object
|
||||
else:
|
||||
q.answer = ""
|
||||
if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_value):
|
||||
self.questions.append(q)
|
||||
|
||||
@property
|
||||
def net_price(self):
|
||||
@@ -1088,9 +1162,9 @@ class OrderPayment(models.Model):
|
||||
"""
|
||||
return self.order.event.get_payment_providers().get(self.provider)
|
||||
|
||||
def _mark_paid(self, force, count_waitinglist, user, auth, overpaid=False):
|
||||
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
|
||||
from pretix.base.signals import order_paid
|
||||
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist)
|
||||
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date)
|
||||
if not force and can_be_paid is not True:
|
||||
self.order.log_action('pretix.event.order.quotaexceeded', {
|
||||
'message': can_be_paid
|
||||
@@ -1110,7 +1184,8 @@ class OrderPayment(models.Model):
|
||||
self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth)
|
||||
order_paid.send(self.order.event, order=self.order)
|
||||
|
||||
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text=''):
|
||||
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
|
||||
ignore_date=False, lock=True, payment_date=None):
|
||||
"""
|
||||
Marks the payment as complete. If possible, this also marks the order as paid if no further
|
||||
payment is required
|
||||
@@ -1120,6 +1195,7 @@ class OrderPayment(models.Model):
|
||||
:type count_waitinglist: boolean
|
||||
:param force: Whether this payment should be marked as paid even if no remaining
|
||||
quota is available (default: ``False``).
|
||||
:param ignore_date: Whether this order should be marked as paid even when the last date of payments is over.
|
||||
:type force: boolean
|
||||
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
|
||||
:type send_mail: boolean
|
||||
@@ -1130,8 +1206,6 @@ class OrderPayment(models.Model):
|
||||
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
|
||||
"""
|
||||
from pretix.base.services.invoices import generate_invoice, invoice_qualified
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
with transaction.atomic():
|
||||
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
|
||||
@@ -1140,7 +1214,7 @@ class OrderPayment(models.Model):
|
||||
return
|
||||
|
||||
locked_instance.state = self.PAYMENT_STATE_CONFIRMED
|
||||
locked_instance.payment_date = now()
|
||||
locked_instance.payment_date = payment_date or now()
|
||||
locked_instance.info = self.info # required for backwards compatibility
|
||||
locked_instance.save(update_fields=['state', 'payment_date', 'info'])
|
||||
|
||||
@@ -1169,14 +1243,16 @@ class OrderPayment(models.Model):
|
||||
if payment_sum - refund_sum < self.order.total:
|
||||
return
|
||||
|
||||
if self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12):
|
||||
if (self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12)) or not lock:
|
||||
# Performance optimization. In this case, there's really no reason to lock everything and an atomic
|
||||
# database transaction is more than enough.
|
||||
with transaction.atomic():
|
||||
self._mark_paid(force, count_waitinglist, user, auth, overpaid=payment_sum - refund_sum > self.order.total)
|
||||
lockfn = NoLockManager
|
||||
else:
|
||||
with self.order.event.lock():
|
||||
self._mark_paid(force, count_waitinglist, user, auth, overpaid=payment_sum - refund_sum > self.order.total)
|
||||
lockfn = self.order.event.lock
|
||||
|
||||
with lockfn():
|
||||
self._mark_paid(force, count_waitinglist, user, auth, overpaid=payment_sum - refund_sum > self.order.total,
|
||||
ignore_date=ignore_date)
|
||||
|
||||
invoice = None
|
||||
if invoice_qualified(self.order):
|
||||
@@ -1193,35 +1269,77 @@ class OrderPayment(models.Model):
|
||||
)
|
||||
|
||||
if send_mail:
|
||||
with language(self.order.locale):
|
||||
try:
|
||||
invoice_name = self.order.invoice_address.name
|
||||
invoice_company = self.order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_template = self.order.event.settings.mail_text_order_paid
|
||||
email_context = {
|
||||
'event': self.order.event.name,
|
||||
'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={
|
||||
'order': self.order.code,
|
||||
'secret': self.order.secret
|
||||
}),
|
||||
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
'payment_info': mail_text
|
||||
}
|
||||
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
|
||||
try:
|
||||
self.order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_paid', user,
|
||||
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order paid email could not be sent')
|
||||
self._send_paid_mail(invoice, user, mail_text)
|
||||
if self.order.event.settings.mail_send_order_paid_attendee:
|
||||
for p in self.order.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != self.order.email:
|
||||
self._send_paid_mail_attendee(p, user)
|
||||
|
||||
def _send_paid_mail_attendee(self, position, user):
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
with language(self.order.locale):
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.order.event.settings.name_scheme]
|
||||
email_template = self.order.event.settings.mail_text_order_paid_attendee
|
||||
email_context = {
|
||||
'event': self.order.event.name,
|
||||
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
|
||||
'url': build_absolute_uri(self.order.event, 'presale:event.order.position', kwargs={
|
||||
'order': self.order.code,
|
||||
'secret': position.web_secret,
|
||||
'position': position.positionid
|
||||
}),
|
||||
'attendee_name': position.attendee_name,
|
||||
}
|
||||
for f, l, w in name_scheme['fields']:
|
||||
email_context['attendee_name_%s' % f] = position.attendee_name_parts.get(f, '')
|
||||
|
||||
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
|
||||
try:
|
||||
self.order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_paid', user,
|
||||
invoices=[], position=position,
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order paid email could not be sent')
|
||||
|
||||
def _send_paid_mail(self, invoice, user, mail_text):
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
with language(self.order.locale):
|
||||
try:
|
||||
invoice_name = self.order.invoice_address.name
|
||||
invoice_company = self.order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_template = self.order.event.settings.mail_text_order_paid
|
||||
email_context = {
|
||||
'event': self.order.event.name,
|
||||
'url': build_absolute_uri(self.order.event, 'presale:event.order.open', kwargs={
|
||||
'order': self.order.code,
|
||||
'secret': self.order.secret,
|
||||
'hash': self.order.email_confirm_hash()
|
||||
}),
|
||||
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
'payment_info': mail_text
|
||||
}
|
||||
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
|
||||
try:
|
||||
self.order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_paid', user,
|
||||
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order paid email could not be sent')
|
||||
|
||||
@property
|
||||
def refunded_amount(self):
|
||||
@@ -1610,6 +1728,7 @@ class OrderPosition(AbstractPosition):
|
||||
verbose_name=_('Tax value')
|
||||
)
|
||||
secret = models.CharField(max_length=64, default=generate_position_secret, db_index=True)
|
||||
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
|
||||
pseudonymization_id = models.CharField(
|
||||
max_length=16,
|
||||
unique=True,
|
||||
@@ -1669,8 +1788,9 @@ class OrderPosition(AbstractPosition):
|
||||
# Delete afterwards. Deleting in between might cause deletion of things related to add-ons
|
||||
# due to the deletion cascade.
|
||||
for cartpos in cp:
|
||||
cartpos.addons.all().delete()
|
||||
cartpos.delete()
|
||||
if cartpos.pk:
|
||||
cartpos.addons.all().delete()
|
||||
cartpos.delete()
|
||||
return ops
|
||||
|
||||
def __str__(self):
|
||||
@@ -1732,6 +1852,60 @@ class OrderPosition(AbstractPosition):
|
||||
def event(self):
|
||||
return self.order.event
|
||||
|
||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
auth=None, attach_tickets=False):
|
||||
"""
|
||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||
|
||||
* Call ``pretix.base.services.mail.mail`` with useful values for the ``event``, ``locale``, ``recipient`` and
|
||||
``order`` parameters.
|
||||
|
||||
* Create a ``LogEntry`` with the email contents.
|
||||
|
||||
:param subject: Subject of the email
|
||||
:param template: LazyI18nString or template filename, see ``pretix.base.services.mail.mail`` for more details
|
||||
:param context: Dictionary to use for rendering the template
|
||||
:param log_entry_type: Key to be used for the log entry
|
||||
:param user: Administrative user who triggered this mail to be sent
|
||||
:param headers: Dictionary with additional mail headers
|
||||
:param sender: Custom email sender.
|
||||
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
||||
"""
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
|
||||
if not self.email:
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.locale):
|
||||
recipient = self.email
|
||||
try:
|
||||
email_content = render_mail(template, context)
|
||||
mail(
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers, sender,
|
||||
invoices=invoices, attach_tickets=attach_tickets
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
else:
|
||||
self.log_action(
|
||||
log_entry_type,
|
||||
user=user,
|
||||
auth=auth,
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'recipient': recipient,
|
||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||
'attach_tickets': attach_tickets,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CartPosition(AbstractPosition):
|
||||
"""
|
||||
@@ -1767,6 +1941,7 @@ class CartPosition(AbstractPosition):
|
||||
includes_tax = models.BooleanField(
|
||||
default=True
|
||||
)
|
||||
is_bundled = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Cart position")
|
||||
|
||||
@@ -33,6 +33,32 @@ class TaxedPrice:
|
||||
money_filter(self.gross, currency)
|
||||
)
|
||||
|
||||
def __sub__(self, other):
|
||||
newgross = self.gross - other.gross
|
||||
newnet = round_decimal(newgross - (newgross * (1 - 100 / (100 + self.rate)))).quantize(
|
||||
Decimal('10') ** self.gross.as_tuple().exponent
|
||||
)
|
||||
return TaxedPrice(
|
||||
gross=newgross,
|
||||
net=newnet,
|
||||
tax=newgross - newnet,
|
||||
rate=self.rate,
|
||||
name=self.name,
|
||||
)
|
||||
|
||||
def __mul__(self, other):
|
||||
newgross = self.gross * other
|
||||
newnet = round_decimal(newgross - (newgross * (1 - 100 / (100 + self.rate)))).quantize(
|
||||
Decimal('10') ** self.gross.as_tuple().exponent
|
||||
)
|
||||
return TaxedPrice(
|
||||
gross=newgross,
|
||||
net=newnet,
|
||||
tax=newgross - newnet,
|
||||
rate=self.rate,
|
||||
name=self.name,
|
||||
)
|
||||
|
||||
|
||||
TAXED_ZERO = TaxedPrice(
|
||||
gross=Decimal('0.00'),
|
||||
@@ -92,6 +118,9 @@ class TaxRule(LoggedModel):
|
||||
)
|
||||
custom_rules = models.TextField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ('event', 'rate', 'id')
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import OrderFee, OrderPosition
|
||||
|
||||
@@ -129,7 +158,12 @@ class TaxRule(LoggedModel):
|
||||
def has_custom_rules(self):
|
||||
return self.custom_rules and self.custom_rules != '[]'
|
||||
|
||||
def tax(self, base_price, base_price_is='auto'):
|
||||
def tax(self, base_price, base_price_is='auto', currency=None):
|
||||
from .event import Event
|
||||
try:
|
||||
currency = currency or self.event.currency
|
||||
except Event.DoesNotExist:
|
||||
pass
|
||||
if self.rate == Decimal('0.00'):
|
||||
return TaxedPrice(
|
||||
net=base_price, gross=base_price, tax=Decimal('0.00'),
|
||||
@@ -145,11 +179,11 @@ class TaxRule(LoggedModel):
|
||||
if base_price_is == 'gross':
|
||||
gross = base_price
|
||||
net = round_decimal(gross - (base_price * (1 - 100 / (100 + self.rate))),
|
||||
self.event.currency if self.event else None)
|
||||
currency)
|
||||
elif base_price_is == 'net':
|
||||
net = base_price
|
||||
gross = round_decimal((net * (1 + self.rate / 100)),
|
||||
self.event.currency if self.event else None)
|
||||
currency)
|
||||
else:
|
||||
raise ValueError('Unknown base price type: {}'.format(base_price_is))
|
||||
|
||||
|
||||
@@ -344,6 +344,8 @@ class Voucher(LoggedModel):
|
||||
a variation).
|
||||
"""
|
||||
if self.quota_id:
|
||||
if variation:
|
||||
return variation.quotas.filter(pk=self.quota_id).exists()
|
||||
return item.quotas.filter(pk=self.quota_id).exists()
|
||||
if self.item_id and not self.variation_id:
|
||||
return self.item_id == item.pk
|
||||
|
||||
@@ -311,7 +311,7 @@ class BasePaymentProvider:
|
||||
@property
|
||||
def payment_form_fields(self) -> dict:
|
||||
"""
|
||||
This is used by the default implementation of :py:meth:`checkout_form`.
|
||||
This is used by the default implementation of :py:meth:`payment_form`.
|
||||
It should return an object similar to :py:attr:`settings_form_fields`.
|
||||
|
||||
The default implementation returns an empty dictionary.
|
||||
@@ -320,10 +320,10 @@ class BasePaymentProvider:
|
||||
|
||||
def payment_form(self, request: HttpRequest) -> Form:
|
||||
"""
|
||||
This is called by the default implementation of :py:meth:`checkout_form_render`
|
||||
This is called by the default implementation of :py:meth:`payment_form_render`
|
||||
to obtain the form that is displayed to the user during the checkout
|
||||
process. The default implementation constructs the form using
|
||||
:py:attr:`checkout_form_fields` and sets appropriate prefixes for the form
|
||||
:py:attr:`payment_form_fields` and sets appropriate prefixes for the form
|
||||
and all fields and fills the form with data form the user's session.
|
||||
|
||||
If you overwrite this, we strongly suggest that you inherit from
|
||||
@@ -437,7 +437,7 @@ class BasePaymentProvider:
|
||||
When the user selects this provider as their preferred payment method,
|
||||
they will be shown the HTML you return from this method.
|
||||
|
||||
The default implementation will call :py:meth:`checkout_form`
|
||||
The default implementation will call :py:meth:`payment_form`
|
||||
and render the returned form. If your payment method doesn't require
|
||||
the user to fill out form fields, you should just return a paragraph
|
||||
of explanatory text.
|
||||
@@ -720,6 +720,22 @@ class BoxOfficeProvider(BasePaymentProvider):
|
||||
def order_change_allowed(self, order: Order) -> bool:
|
||||
return False
|
||||
|
||||
def payment_control_render(self, request, payment) -> str:
|
||||
if not payment.info:
|
||||
return
|
||||
payment_info = json.loads(payment.info)
|
||||
template = get_template('pretixcontrol/boxoffice/payment.html')
|
||||
|
||||
ctx = {
|
||||
'request': request,
|
||||
'event': self.event,
|
||||
'settings': self.settings,
|
||||
'payment_info': payment_info,
|
||||
'payment': payment,
|
||||
'provider': self,
|
||||
}
|
||||
return template.render(ctx)
|
||||
|
||||
|
||||
class ManualPayment(BasePaymentProvider):
|
||||
identifier = 'manual'
|
||||
|
||||
@@ -87,6 +87,15 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": _("123.45 EUR"),
|
||||
"evaluate": lambda op, order, event: money_filter(op.price, event.currency)
|
||||
}),
|
||||
("price_with_addons", {
|
||||
"label": _("Price including add-ons"),
|
||||
"editor_sample": _("123.45 EUR"),
|
||||
"evaluate": lambda op, order, event: money_filter(op.price + sum(
|
||||
p.price
|
||||
for p in op.addons.all()
|
||||
if not p.canceled
|
||||
), event.currency)
|
||||
}),
|
||||
("attendee_name", {
|
||||
"label": _("Attendee name"),
|
||||
"editor_sample": _("John Doe"),
|
||||
@@ -192,6 +201,7 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
|
||||
else op.addons.select_related('item', 'variation')
|
||||
)
|
||||
if not p.canceled
|
||||
])
|
||||
}),
|
||||
("organizer", {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import sys
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
|
||||
from django.apps import apps
|
||||
from django.apps import AppConfig, apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
|
||||
class PluginType(Enum):
|
||||
@@ -39,3 +41,22 @@ def get_all_plugins(event=None) -> List[type]:
|
||||
plugins,
|
||||
key=lambda m: (0 if m.module.startswith('pretix.') else 1, str(m.name).lower().replace('pretix ', ''))
|
||||
)
|
||||
|
||||
|
||||
class PluginConfig(AppConfig):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not hasattr(self, 'PretixPluginMeta'):
|
||||
raise ImproperlyConfigured("A pretix plugin config should have a PretixPluginMeta inner class.")
|
||||
|
||||
if hasattr(self.PretixPluginMeta, 'compatibility'):
|
||||
import pkg_resources
|
||||
try:
|
||||
pkg_resources.require(self.PretixPluginMeta.compatibility)
|
||||
except pkg_resources.VersionConflict as e:
|
||||
print("Incompatible plugins found!")
|
||||
print("Plugin {} requires you to have {}, but you installed {}.".format(
|
||||
self.name, e.req, e.dist
|
||||
))
|
||||
sys.exit(1)
|
||||
|
||||
@@ -71,7 +71,7 @@ class RelativeDateWrapper:
|
||||
else:
|
||||
base_date = getattr(event, self.data.base_date_name) or event.date_from
|
||||
|
||||
oldoffset = base_date.utcoffset()
|
||||
oldoffset = base_date.astimezone(tz).utcoffset()
|
||||
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before)
|
||||
if self.data.time:
|
||||
new_date = new_date.replace(
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
from collections import Counter, defaultdict, namedtuple
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db import DatabaseError, transaction
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext as _
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, InvoiceAddress, Item, ItemVariation, Voucher,
|
||||
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation,
|
||||
Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.orders import OrderFee
|
||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.checkin import _save_answers
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
from pretix.base.services.locking import LockTimeoutException, NoLockManager
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.tasks import ProfiledTask
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
@@ -87,12 +89,13 @@ error_messages = {
|
||||
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
|
||||
'product %(base)s.'),
|
||||
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
|
||||
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
|
||||
}
|
||||
|
||||
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
|
||||
'addon_to', 'subevent', 'includes_tax'))
|
||||
'addon_to', 'subevent', 'includes_tax', 'bundled'))
|
||||
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
|
||||
'quotas', 'subevent'))
|
||||
@@ -132,6 +135,15 @@ class CartManager:
|
||||
raise CartError(error_messages['not_started'])
|
||||
if self.event.presale_has_ended:
|
||||
raise CartError(error_messages['ended'])
|
||||
if not self.event.has_subevents:
|
||||
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||
if tlv:
|
||||
term_last = make_aware(datetime.combine(
|
||||
tlv.datetime(self.event).date(),
|
||||
time(hour=23, minute=59, second=59)
|
||||
), self.event.timezone)
|
||||
if term_last < self.now_dt:
|
||||
raise CartError(error_messages['ended'])
|
||||
|
||||
def _extend_expiry_of_valid_existing_positions(self):
|
||||
# Extend this user's cart session to ensure all items in the cart expire at the same time
|
||||
@@ -150,6 +162,18 @@ class CartManager:
|
||||
err = error_messages['some_subevent_ended']
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
|
||||
if cp.subevent:
|
||||
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||
if tlv:
|
||||
term_last = make_aware(datetime.combine(
|
||||
tlv.datetime(cp.subevent).date(),
|
||||
time(hour=23, minute=59, second=59)
|
||||
), self.event.timezone)
|
||||
if term_last < self.now_dt:
|
||||
err = error_messages['some_subevent_ended']
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
return err
|
||||
|
||||
def _update_subevents_cache(self, se_ids: List[int]):
|
||||
@@ -162,7 +186,7 @@ class CartManager:
|
||||
self._items_cache.update({
|
||||
i.pk: i
|
||||
for i in self.event.items.select_related('category').prefetch_related(
|
||||
'addons', 'addons__addon_category', 'quotas'
|
||||
'addons', 'bundles', 'addons__addon_category', 'quotas'
|
||||
).filter(
|
||||
id__in=[i for i in item_ids if i and i not in self._items_cache]
|
||||
)
|
||||
@@ -214,10 +238,23 @@ class CartManager:
|
||||
if op.subevent and op.subevent.presale_has_ended:
|
||||
raise CartError(error_messages['ended'])
|
||||
|
||||
if op.subevent:
|
||||
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||
if tlv:
|
||||
term_last = make_aware(datetime.combine(
|
||||
tlv.datetime(op.subevent).date(),
|
||||
time(hour=23, minute=59, second=59)
|
||||
), self.event.timezone)
|
||||
if term_last < self.now_dt:
|
||||
raise CartError(error_messages['ended'])
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
if op.item.category and op.item.category.is_addon and not op.addon_to:
|
||||
if op.item.category and op.item.category.is_addon and not (op.addon_to and op.addon_to != 'FAKE'):
|
||||
raise CartError(error_messages['addon_only'])
|
||||
|
||||
if op.item.require_bundling and not op.addon_to == 'FAKE':
|
||||
raise CartError(error_messages['bundled_only'])
|
||||
|
||||
if op.item.max_per_order or op.item.min_per_order:
|
||||
new_total = (
|
||||
len([1 for p in self.positions if p.item_id == op.item.pk]) +
|
||||
@@ -246,12 +283,13 @@ class CartManager:
|
||||
|
||||
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
||||
voucher: Optional[Voucher], custom_price: Optional[Decimal],
|
||||
subevent: Optional[SubEvent], cp_is_net: bool=None):
|
||||
subevent: Optional[SubEvent], cp_is_net: bool=None, force_custom_price=False,
|
||||
bundled_sum=Decimal('0.00')):
|
||||
try:
|
||||
return get_price(
|
||||
item, variation, voucher, custom_price, subevent,
|
||||
custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices,
|
||||
invoice_address=self.invoice_address
|
||||
invoice_address=self.invoice_address, force_custom_price=force_custom_price, bundled_sum=bundled_sum
|
||||
)
|
||||
except ValueError as e:
|
||||
if str(e) == 'price_too_high':
|
||||
@@ -261,22 +299,56 @@ class CartManager:
|
||||
|
||||
def extend_expired_positions(self):
|
||||
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
|
||||
'item', 'variation', 'voucher'
|
||||
).prefetch_related('item__quotas', 'variation__quotas')
|
||||
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
|
||||
).prefetch_related(
|
||||
'item__quotas',
|
||||
'variation__quotas',
|
||||
'addons'
|
||||
).order_by('-is_bundled')
|
||||
err = None
|
||||
changed_prices = {}
|
||||
for cp in expired:
|
||||
if not cp.includes_tax:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||
cp_is_net=True)
|
||||
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
|
||||
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
|
||||
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
|
||||
continue
|
||||
|
||||
if cp.is_bundled:
|
||||
try:
|
||||
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
|
||||
price = bundle.designated_price or 0
|
||||
except ItemBundle.DoesNotExist:
|
||||
price = cp.price
|
||||
|
||||
changed_prices[cp.pk] = price
|
||||
|
||||
if not cp.includes_tax:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
|
||||
force_custom_price=True, cp_is_net=False)
|
||||
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
|
||||
else:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
|
||||
force_custom_price=True)
|
||||
else:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent)
|
||||
bundled_sum = Decimal('0.00')
|
||||
if not cp.addon_to_id:
|
||||
for bundledp in cp.addons.all():
|
||||
if bundledp.is_bundled:
|
||||
bundledprice = changed_prices.get(bundledp.pk, bundledp.price)
|
||||
bundled_sum += bundledprice
|
||||
|
||||
if not cp.includes_tax:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||
cp_is_net=True, bundled_sum=bundled_sum)
|
||||
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
|
||||
else:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||
bundled_sum=bundled_sum)
|
||||
|
||||
quotas = list(cp.quotas)
|
||||
if not quotas:
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
continue
|
||||
err = error_messages['unavailable']
|
||||
continue
|
||||
|
||||
if not cp.voucher or (not cp.voucher.allow_ignore_quota and not cp.voucher.block_quota):
|
||||
for quota in quotas:
|
||||
@@ -341,10 +413,48 @@ class CartManager:
|
||||
else:
|
||||
quotas = []
|
||||
|
||||
price = self._get_price(item, variation, voucher, i.get('price'), subevent)
|
||||
# Fetch bundled items
|
||||
bundled = []
|
||||
bundled_sum = Decimal('0.00')
|
||||
db_bundles = list(item.bundles.all())
|
||||
self._update_items_cache([b.bundled_item_id for b in db_bundles], [b.bundled_variation_id for b in db_bundles])
|
||||
for bundle in db_bundles:
|
||||
if bundle.bundled_item_id not in self._items_cache or (
|
||||
bundle.bundled_variation_id and bundle.bundled_variation_id not in self._variations_cache
|
||||
):
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
bitem = self._items_cache[bundle.bundled_item_id]
|
||||
bvar = self._variations_cache[bundle.bundled_variation_id] if bundle.bundled_variation_id else None
|
||||
bundle_quotas = list(bitem.quotas.filter(subevent=subevent)
|
||||
if bvar is None else bvar.quotas.filter(subevent=subevent))
|
||||
if not bundle_quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
if not voucher or not voucher.allow_ignore_quota:
|
||||
for quota in bundle_quotas:
|
||||
quota_diff[quota] += bundle.count * i['count']
|
||||
else:
|
||||
bundle_quotas = []
|
||||
|
||||
if bundle.designated_price:
|
||||
bprice = self._get_price(bitem, bvar, None, bundle.designated_price, subevent, force_custom_price=True,
|
||||
cp_is_net=False)
|
||||
else:
|
||||
bprice = TAXED_ZERO
|
||||
bundled_sum += bundle.designated_price * bundle.count
|
||||
|
||||
bop = self.AddOperation(
|
||||
count=bundle.count, item=bitem, variation=bvar, price=bprice,
|
||||
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
|
||||
includes_tax=bool(bprice.rate), bundled=[]
|
||||
)
|
||||
self._check_item_constraints(bop)
|
||||
bundled.append(bop)
|
||||
|
||||
price = self._get_price(item, variation, voucher, i.get('price'), subevent, bundled_sum=bundled_sum)
|
||||
|
||||
op = self.AddOperation(
|
||||
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
|
||||
addon_to=False, subevent=subevent, includes_tax=bool(price.rate)
|
||||
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
@@ -403,6 +513,7 @@ class CartManager:
|
||||
current_addons[cp] = {
|
||||
(a.item_id, a.variation_id): a
|
||||
for a in cp.addons.all()
|
||||
if not a.is_bundled
|
||||
}
|
||||
|
||||
# Create operations, perform various checks
|
||||
@@ -449,7 +560,7 @@ class CartManager:
|
||||
|
||||
op = self.AddOperation(
|
||||
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
|
||||
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate)
|
||||
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[]
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
@@ -523,7 +634,7 @@ class CartManager:
|
||||
Q(voucher=voucher) & Q(event=self.event) &
|
||||
Q(expires__gte=self.now_dt)
|
||||
).exclude(pk__in=[
|
||||
op.position.voucher_id for op in self._operations if isinstance(op, self.ExtendOperation)
|
||||
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
|
||||
])
|
||||
cart_count = redeemed_in_carts.count()
|
||||
v_avail = voucher.max_usages - voucher.redeemed - cart_count
|
||||
@@ -609,11 +720,29 @@ class CartManager:
|
||||
|
||||
available_count = min(quota_available_count, voucher_available_count)
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
for b in op.bundled:
|
||||
b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b.quotas))
|
||||
if b_quota_available_count < b.count:
|
||||
err = err or error_messages['unavailable']
|
||||
available_count = 0
|
||||
elif b_quota_available_count < available_count * b.count:
|
||||
err = err or error_messages['in_part']
|
||||
available_count = b_quota_available_count // b.count
|
||||
for q in b.quotas:
|
||||
quotas_ok[q] -= available_count * b.count
|
||||
# TODO: is this correct?
|
||||
|
||||
for q in op.quotas:
|
||||
quotas_ok[q] -= available_count
|
||||
if op.voucher:
|
||||
vouchers_ok[op.voucher] -= available_count
|
||||
|
||||
if any(qa < 0 for qa in quotas_ok.values()):
|
||||
# Safeguard, shouldn't happen
|
||||
err = err or error_messages['unavailable']
|
||||
available_count = 0
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
for k in range(available_count):
|
||||
cp = CartPosition(
|
||||
@@ -646,12 +775,27 @@ class CartManager:
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
if op.bundled:
|
||||
cp.save() # Needs to be in the database already so we have a PK that we can reference
|
||||
for b in op.bundled:
|
||||
for j in range(b.count):
|
||||
new_cart_positions.append(CartPosition(
|
||||
event=self.event, item=b.item, variation=b.variation,
|
||||
price=b.price.gross, expires=self._expiry, cart_id=self.cart_id,
|
||||
voucher=None, addon_to=cp,
|
||||
subevent=b.subevent, includes_tax=b.includes_tax, is_bundled=True
|
||||
))
|
||||
|
||||
new_cart_positions.append(cp)
|
||||
elif isinstance(op, self.ExtendOperation):
|
||||
if available_count == 1:
|
||||
op.position.expires = self._expiry
|
||||
op.position.price = op.price.gross
|
||||
op.position.save()
|
||||
try:
|
||||
op.position.save(force_update=True)
|
||||
except DatabaseError:
|
||||
# Best effort... The position might have been deleted in the meantime!
|
||||
pass
|
||||
elif available_count == 0:
|
||||
op.position.addons.all().delete()
|
||||
op.position.delete()
|
||||
@@ -659,23 +803,40 @@ class CartManager:
|
||||
raise AssertionError("ExtendOperation cannot affect more than one item")
|
||||
|
||||
for p in new_cart_positions:
|
||||
if p._answers:
|
||||
p.save()
|
||||
if getattr(p, '_answers', None):
|
||||
if not p.pk: # We stored some to the database already before
|
||||
p.save()
|
||||
_save_answers(p, {}, p._answers)
|
||||
CartPosition.objects.bulk_create([p for p in new_cart_positions if not p._answers])
|
||||
CartPosition.objects.bulk_create([p for p in new_cart_positions if not getattr(p, '_answers', None) and not p.pk])
|
||||
return err
|
||||
|
||||
def _require_locking(self):
|
||||
if self._voucher_use_diff:
|
||||
# If any vouchers are used, we lock to make sure we don't redeem them to often
|
||||
return True
|
||||
|
||||
if self._quota_diff and any(q.size is not None for q in self._quota_diff):
|
||||
# If any quotas are affected that are not unlimited, we lock
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def commit(self):
|
||||
self._check_presale_dates()
|
||||
self._check_max_cart_size()
|
||||
self._calculate_expiry()
|
||||
|
||||
with self.event.lock() as now_dt:
|
||||
err = self._delete_out_of_timeframe()
|
||||
err = self.extend_expired_positions() or err
|
||||
|
||||
lockfn = NoLockManager
|
||||
if self._require_locking():
|
||||
lockfn = self.event.lock
|
||||
|
||||
with lockfn() as now_dt:
|
||||
with transaction.atomic():
|
||||
self.now_dt = now_dt
|
||||
self._extend_expiry_of_valid_existing_positions()
|
||||
err = self._delete_out_of_timeframe()
|
||||
err = self.extend_expired_positions() or err
|
||||
err = self._perform_operations() or err
|
||||
if err:
|
||||
raise CartError(err)
|
||||
@@ -747,7 +908,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
"""
|
||||
Adds a list of items to a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param items: A list of dicts with the keys item, variation, number, custom_price, voucher
|
||||
:param items: A list of dicts with the keys item, variation, count, custom_price, voucher
|
||||
:param cart_id: Session ID of a guest
|
||||
:raises CartError: On any error that occured
|
||||
"""
|
||||
|
||||
@@ -2,15 +2,20 @@ from typing import Any, Dict
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.timezone import override
|
||||
from django.utils.translation import ugettext
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import CachedFile, Event, cachedfile_name
|
||||
from pretix.base.services.tasks import ProfiledTask
|
||||
from pretix.base.signals import register_data_exporters
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
class ExportError(LazyLocaleException):
|
||||
pass
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, throws=(ExportError,))
|
||||
def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
|
||||
event = Event.objects.get(id=event)
|
||||
file = CachedFile.objects.get(id=fileid)
|
||||
@@ -19,7 +24,12 @@ def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) ->
|
||||
for receiver, response in responses:
|
||||
ex = response(event)
|
||||
if ex.identifier == provider:
|
||||
file.filename, file.type, data = ex.render(form_data)
|
||||
d = ex.render(form_data)
|
||||
if d is None:
|
||||
raise ExportError(
|
||||
ugettext('Your export did not contain any data.')
|
||||
)
|
||||
file.filename, file.type, data = d
|
||||
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
|
||||
file.save()
|
||||
return file.pk
|
||||
|
||||
@@ -120,7 +120,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
positions = list(
|
||||
invoice.order.positions.select_related('addon_to', 'item', 'tax_rule', 'subevent', 'variation').annotate(
|
||||
addon_c=Count('addons')
|
||||
)
|
||||
).order_by('positionid', 'id')
|
||||
)
|
||||
|
||||
reverse_charge = False
|
||||
@@ -257,7 +257,8 @@ def invoice_pdf_task(invoice: int):
|
||||
|
||||
|
||||
def invoice_qualified(order: Order):
|
||||
if order.total == Decimal('0.00') or order.require_approval:
|
||||
if order.total == Decimal('0.00') or order.require_approval or \
|
||||
order.sales_channel not in order.event.settings.get('invoice_generate_sales_channels'):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -13,6 +13,18 @@ logger = logging.getLogger('pretix.base.locking')
|
||||
LOCK_TIMEOUT = 120
|
||||
|
||||
|
||||
class NoLockManager:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return now()
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if exc_type is not None:
|
||||
return False
|
||||
|
||||
|
||||
class LockManager:
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user