forked from CGM_Public/pretix_original
Compare commits
1146 Commits
release/1.
...
loadtest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e9d09e1f | ||
|
|
93947cace0 | ||
|
|
d4eac76a8d | ||
|
|
8889607d1c | ||
|
|
5e9e00acec | ||
|
|
0e89d4c0f7 | ||
|
|
8b3ce69425 | ||
|
|
b20d1e8373 | ||
|
|
c278687487 | ||
|
|
0c45e73456 | ||
|
|
104f84b7a8 | ||
|
|
ac4ecfbe69 | ||
|
|
61c6cd2937 | ||
|
|
38066ca5ab | ||
|
|
373ab29701 | ||
|
|
7302bba602 | ||
|
|
5096121ac7 | ||
|
|
ca4c21a843 | ||
|
|
407ecdf6c5 | ||
|
|
2faeee8e9c | ||
|
|
e1bbf7139f | ||
|
|
64fc38a06e | ||
|
|
6bcf884b7a | ||
|
|
d319293da8 | ||
|
|
832c58d288 | ||
|
|
c251e0e7d3 | ||
|
|
27437e065a | ||
|
|
86534aa7cc | ||
|
|
379a2140c8 | ||
|
|
67059fe323 | ||
|
|
8ffc96bf31 | ||
|
|
58b688628e | ||
|
|
3f7348717b | ||
|
|
90c8e0c172 | ||
|
|
d35ad345d7 | ||
|
|
21634369a8 | ||
|
|
a2b075c0d7 | ||
|
|
0617abe6e3 | ||
|
|
040466353c | ||
|
|
46b7e9467b | ||
|
|
283ff3b5e5 | ||
|
|
b0bb22ea38 | ||
|
|
334ee98318 | ||
|
|
c4d342029b | ||
|
|
bc86f9c059 | ||
|
|
51107fe4fd | ||
|
|
3d65c2fd51 | ||
|
|
9b394b3833 | ||
|
|
d5747084ec | ||
|
|
777772b89e | ||
|
|
c202286470 | ||
|
|
0c1738b9bb | ||
|
|
af607083cb | ||
|
|
def7918b29 | ||
|
|
0933fc848d | ||
|
|
166f8b8a2a | ||
|
|
70fcba96a5 | ||
|
|
2d2d62045a | ||
|
|
3988f1e2f6 | ||
|
|
d3ecb92108 | ||
|
|
b3debdfb55 | ||
|
|
abb770a8e7 | ||
|
|
72a2d0da35 | ||
|
|
937cec53f7 | ||
|
|
6e4af5da64 | ||
|
|
7ed35e06ba | ||
|
|
55841ea660 | ||
|
|
78544cdb30 | ||
|
|
37183aced7 | ||
|
|
a7d3cb134c | ||
|
|
da8f7f163f | ||
|
|
89d612beed | ||
|
|
f23de7e2c0 | ||
|
|
d073007fd7 | ||
|
|
d9d1c83218 | ||
|
|
ae9b8bafb8 | ||
|
|
cbf5c2ec1d | ||
|
|
17392f3ef4 | ||
|
|
bf36ad009f | ||
|
|
ca9e4823e2 | ||
|
|
d505422e0f | ||
|
|
33c43ce482 | ||
|
|
f273cf4960 | ||
|
|
afdf09eeb4 | ||
|
|
01e5872f61 | ||
|
|
14cc31c810 | ||
|
|
2972129547 | ||
|
|
ec4227651a | ||
|
|
77950de588 | ||
|
|
187576eee5 | ||
|
|
0e513a0985 | ||
|
|
1cde728ffe | ||
|
|
76893caffc | ||
|
|
a539999c04 | ||
|
|
b9c570b3d8 | ||
|
|
48b399424a | ||
|
|
1c73f000a9 | ||
|
|
d0721165c1 | ||
|
|
bed0a0ceeb | ||
|
|
b53ee1dc1d | ||
|
|
41b56c00e5 | ||
|
|
cb17febf7c | ||
|
|
07d42a4d77 | ||
|
|
e3ebf887a4 | ||
|
|
0440187e59 | ||
|
|
dfcda0fa2c | ||
|
|
560c0a8729 | ||
|
|
bc80b60b04 | ||
|
|
08bf3648ea | ||
|
|
f8ee7acad6 | ||
|
|
10c86869ea | ||
|
|
9034a98df9 | ||
|
|
a7142fdf55 | ||
|
|
ee97c46aec | ||
|
|
7063f32f24 | ||
|
|
2ec926b7c7 | ||
|
|
834b5a26a5 | ||
|
|
90f08d0aca | ||
|
|
d5c2637198 | ||
|
|
f517ba51bd | ||
|
|
d738198ec5 | ||
|
|
b1ce58d06c | ||
|
|
b26ef74128 | ||
|
|
4f8c8ea917 | ||
|
|
0803b049af | ||
|
|
97f3fbdb80 | ||
|
|
434b6e4729 | ||
|
|
f56bceb55f | ||
|
|
2aa246b3d5 | ||
|
|
f77b551aa6 | ||
|
|
c9415cba2b | ||
|
|
4dae224d73 | ||
|
|
13cc57e98b | ||
|
|
6f980b82ac | ||
|
|
f32c581a9e | ||
|
|
fcadfffb92 | ||
|
|
9e43459879 | ||
|
|
87424c25de | ||
|
|
acdf7d62b5 | ||
|
|
944138f7a9 | ||
|
|
5da2eab1fb | ||
|
|
d680937a6c | ||
|
|
f35c2544b6 | ||
|
|
0285cd12f7 | ||
|
|
03cacace57 | ||
|
|
6ed016e49e | ||
|
|
da8da01614 | ||
|
|
9a2ea6699a | ||
|
|
51a8bac9e6 | ||
|
|
303ed07504 | ||
|
|
c7627f631f | ||
|
|
604c31c6e2 | ||
|
|
c3da6731a1 | ||
|
|
6e556ab09b | ||
|
|
16622883f6 | ||
|
|
cce4379d3e | ||
|
|
5af99f4f1a | ||
|
|
9ed49888b8 | ||
|
|
5bfb00db73 | ||
|
|
a031d72ca9 | ||
|
|
15a190cdf3 | ||
|
|
d181375479 | ||
|
|
d8a57b0baa | ||
|
|
d482bc9de0 | ||
|
|
5c030796d7 | ||
|
|
f6eb3bfb80 | ||
|
|
3703fbcacf | ||
|
|
cdea6eb55e | ||
|
|
bf1e9d47d0 | ||
|
|
350df2a3cc | ||
|
|
bc6915b251 | ||
|
|
f9c7eeff9a | ||
|
|
247bcf0a20 | ||
|
|
455c961fc7 | ||
|
|
9052d4a7a9 | ||
|
|
589401e8d2 | ||
|
|
0c366a8473 | ||
|
|
c9ddbd0e88 | ||
|
|
31bf0c24f1 | ||
|
|
c74386346b | ||
|
|
725e1f019e | ||
|
|
06eddb2c6d | ||
|
|
80b5750756 | ||
|
|
f37d265534 | ||
|
|
7c4a1e5fb8 | ||
|
|
9a045c76ec | ||
|
|
447b36fdd3 | ||
|
|
5dbd984178 | ||
|
|
95f96f8321 | ||
|
|
3933032778 | ||
|
|
d0b18d9f64 | ||
|
|
71de71ed37 | ||
|
|
3438d079d5 | ||
|
|
e7730333c2 | ||
|
|
e8b9f0a3ae | ||
|
|
77ebd18404 | ||
|
|
2d48198c83 | ||
|
|
d103b0bb84 | ||
|
|
01411b84e4 | ||
|
|
b7e154d8c9 | ||
|
|
f39ac96322 | ||
|
|
74db808978 | ||
|
|
ab72b93706 | ||
|
|
af5aece639 | ||
|
|
228ab15900 | ||
|
|
66164d8202 | ||
|
|
d5ac155914 | ||
|
|
75a966529e | ||
|
|
28a6a6185d | ||
|
|
07cdaa9ca9 | ||
|
|
1c6935ebd9 | ||
|
|
60c1ea8aad | ||
|
|
0b8798a65c | ||
|
|
a8836cbeec | ||
|
|
336a34b10b | ||
|
|
c5862cc0a0 | ||
|
|
89cdcd3781 | ||
|
|
2837cac554 | ||
|
|
3b54556739 | ||
|
|
4d6d6ff737 | ||
|
|
ffee31e415 | ||
|
|
8abfbba9d0 | ||
|
|
588955901c | ||
|
|
4b7bf2f27f | ||
|
|
664957e886 | ||
|
|
f15a6d39c3 | ||
|
|
3fd80a9a46 | ||
|
|
2fd2716303 | ||
|
|
37315fc380 | ||
|
|
f96fc0744e | ||
|
|
5bb7883020 | ||
|
|
3f95434922 | ||
|
|
08da5a8b91 | ||
|
|
97dc4421ea | ||
|
|
26ca2ff006 | ||
|
|
980c359f57 | ||
|
|
ff1198dec6 | ||
|
|
7275de94af | ||
|
|
ed46f41f8c | ||
|
|
1078e38890 | ||
|
|
2e9bbff308 | ||
|
|
13a48701fa | ||
|
|
ddc9c850c0 | ||
|
|
fa0dae6ed6 | ||
|
|
da6176a51e | ||
|
|
4ef6659551 | ||
|
|
82624a1dc0 | ||
|
|
b50add260a | ||
|
|
f72f97d366 | ||
|
|
ad46e9e541 | ||
|
|
343dbc00be | ||
|
|
3cb94f702d | ||
|
|
ddeae224fb | ||
|
|
3c57895101 | ||
|
|
687c85eb58 | ||
|
|
90ffdbdfa3 | ||
|
|
654be0db34 | ||
|
|
82e3359b40 | ||
|
|
01a6861453 | ||
|
|
7f6cdd6241 | ||
|
|
aad1fda31f | ||
|
|
ad462921f0 | ||
|
|
dc433f6420 | ||
|
|
2d8b3d1c79 | ||
|
|
eb85fa956e | ||
|
|
215514fca7 | ||
|
|
3fe2dfe810 | ||
|
|
041d05eb66 | ||
|
|
d05530ddfc | ||
|
|
734e77d1a3 | ||
|
|
633061e203 | ||
|
|
e11ee4a427 | ||
|
|
1edcd47703 | ||
|
|
cf4b2544f2 | ||
|
|
04c3cffd43 | ||
|
|
483d41c7a6 | ||
|
|
b0c4c88d01 | ||
|
|
518298f71c | ||
|
|
62c2e7765b | ||
|
|
2bb2a40509 | ||
|
|
49828186b0 | ||
|
|
c07a6cb4aa | ||
|
|
67ad9a0dcb | ||
|
|
d267dfc682 | ||
|
|
eed220f14a | ||
|
|
85289fe0d1 | ||
|
|
6293ad34d4 | ||
|
|
0dc4f61cf0 | ||
|
|
6849e682d7 | ||
|
|
6b725a9db9 | ||
|
|
989ebbb444 | ||
|
|
0a6efc1e0f | ||
|
|
d577a0d286 | ||
|
|
6b9b379ce2 | ||
|
|
13234b6fd5 | ||
|
|
2fa0067663 | ||
|
|
4e37fa5778 | ||
|
|
bfb74448b1 | ||
|
|
a255082b07 | ||
|
|
14df35bd90 | ||
|
|
bd0ba7baa5 | ||
|
|
9aa220b95b | ||
|
|
3ed4be63fe | ||
|
|
23f4b0b62f | ||
|
|
4b9acb64da | ||
|
|
ebba0ee0cb | ||
|
|
335ce48d7e | ||
|
|
d9a0c8c523 | ||
|
|
a297bd1944 | ||
|
|
953ea26984 | ||
|
|
e4f80f7660 | ||
|
|
128a185957 | ||
|
|
0bdd14b47a | ||
|
|
3b84b181ad | ||
|
|
c9b0626324 | ||
|
|
dc9a82cade | ||
|
|
8266733e34 | ||
|
|
246987955b | ||
|
|
b93e7fcb60 | ||
|
|
b1cebdbd99 | ||
|
|
d04047abd5 | ||
|
|
efca46945a | ||
|
|
0f9755e36f | ||
|
|
478d8e4116 | ||
|
|
81693e042c | ||
|
|
47b7d7b36c | ||
|
|
ba15c34ce1 | ||
|
|
94f2ad9325 | ||
|
|
d8070ba8a3 | ||
|
|
b1019672b0 | ||
|
|
631307a4d5 | ||
|
|
180a26ee1d | ||
|
|
7eab1982fe | ||
|
|
ca59237ebf | ||
|
|
cc92210dc2 | ||
|
|
6602afdd6c | ||
|
|
c7a04bc08a | ||
|
|
2cc5b7f4e8 | ||
|
|
453f16af03 | ||
|
|
0f3398ae13 | ||
|
|
f1b65c8695 | ||
|
|
2c4c89c8c2 | ||
|
|
4042b603b7 | ||
|
|
63b0288383 | ||
|
|
7c01fee70b | ||
|
|
8127c32ef5 | ||
|
|
563decdfba | ||
|
|
a205b01d70 | ||
|
|
b4290384e1 | ||
|
|
0f76779fb1 | ||
|
|
f34c528cba | ||
|
|
cf01e04101 | ||
|
|
a3a63def55 | ||
|
|
a3489eea04 | ||
|
|
c6cb98c30a | ||
|
|
332c58c82f | ||
|
|
beb0ded6dc | ||
|
|
b49b2035bd | ||
|
|
106c8d373d | ||
|
|
aee44a3284 | ||
|
|
d4c1fcf838 | ||
|
|
832f57c9d7 | ||
|
|
ac2a9b207d | ||
|
|
f1e5d60a14 | ||
|
|
7b1a1dc754 | ||
|
|
c93f804992 | ||
|
|
1cba4b1d45 | ||
|
|
22369a5559 | ||
|
|
a8223ad354 | ||
|
|
c9d3cf7cac | ||
|
|
bbdbc94f6e | ||
|
|
5c8d9c4dca | ||
|
|
546ff6e42f | ||
|
|
7b7d45ce2e | ||
|
|
be3ca7c561 | ||
|
|
abdb6e2d52 | ||
|
|
138ddcdcd7 | ||
|
|
8ffc6550da | ||
|
|
0734715bab | ||
|
|
7528bfb10b | ||
|
|
2798fb3468 | ||
|
|
4e6f4716ec | ||
|
|
e523a4e610 | ||
|
|
31cec76809 | ||
|
|
fdfd9f9275 | ||
|
|
b658c73c19 | ||
|
|
ebd3e6f31a | ||
|
|
ccec114653 | ||
|
|
f0716dc482 | ||
|
|
513778b2c4 | ||
|
|
742e403ae2 | ||
|
|
09a9d610f8 | ||
|
|
b9534f23f5 | ||
|
|
b053f61001 | ||
|
|
21042f2111 | ||
|
|
e953474138 | ||
|
|
0d438ad07c | ||
|
|
e285b7cff0 | ||
|
|
2bb2f30e66 | ||
|
|
9a8d23f582 | ||
|
|
f37d12e056 | ||
|
|
334ffc0be7 | ||
|
|
03f0da4ee6 | ||
|
|
fbbd6eebc0 | ||
|
|
584ced87db | ||
|
|
901953d988 | ||
|
|
8c34a47138 | ||
|
|
0fe3db634c | ||
|
|
d8d838fc4f | ||
|
|
9b94a1b3b2 | ||
|
|
479abc1a65 | ||
|
|
1a17ba13ca | ||
|
|
371c42b738 | ||
|
|
ed85394845 | ||
|
|
a9a684a456 | ||
|
|
d7d7792a4a | ||
|
|
c09587f5d3 | ||
|
|
23f719381c | ||
|
|
d74d39d6e9 | ||
|
|
5f2cf8d3ef | ||
|
|
1843799345 | ||
|
|
bd838b3b7c | ||
|
|
c2d03f5e6b | ||
|
|
74e8e73877 | ||
|
|
8830dc8f78 | ||
|
|
ac877a7c0d | ||
|
|
0a442e712b | ||
|
|
4477f8001e | ||
|
|
152b94428f | ||
|
|
5390b0b191 | ||
|
|
97de8cea08 | ||
|
|
cd465c1aad | ||
|
|
449dea41a8 | ||
|
|
0b1a6e4745 | ||
|
|
e49061e28c | ||
|
|
18cb29b425 | ||
|
|
994ff23719 | ||
|
|
15d077df6e | ||
|
|
b490aa7f5d | ||
|
|
ca6b3badde | ||
|
|
1f200271af | ||
|
|
894a60d016 | ||
|
|
4a2219134b | ||
|
|
7d38fc5c03 | ||
|
|
ef5de187b9 | ||
|
|
a1c424266b | ||
|
|
557b4b7b6f | ||
|
|
98be21253d | ||
|
|
e5a04ada94 | ||
|
|
9b8b3090e6 | ||
|
|
e622c3948d | ||
|
|
94be46ffdb | ||
|
|
7039374588 | ||
|
|
0a5347c08b | ||
|
|
87f3318431 | ||
|
|
2557a8e4ec | ||
|
|
aff7094cb0 | ||
|
|
5a29b4bf70 | ||
|
|
e618183b49 | ||
|
|
a18236b12d | ||
|
|
b5da4e89a6 | ||
|
|
1da2737427 | ||
|
|
032fdadc3c | ||
|
|
8ae3ff3fe6 | ||
|
|
b8669503fa | ||
|
|
863165caaa | ||
|
|
b885f30789 | ||
|
|
461b62bd51 | ||
|
|
23776db3b6 | ||
|
|
19e91a6c7c | ||
|
|
6f40325d3f | ||
|
|
1987bff4b1 | ||
|
|
5aa0d55d47 | ||
|
|
a28196e930 | ||
|
|
c55387819d | ||
|
|
c8cc527aee | ||
|
|
a39b207ad5 | ||
|
|
ea63b50f2e | ||
|
|
b101251aa4 | ||
|
|
c9ba72ebc5 | ||
|
|
4a1c3088a9 | ||
|
|
a480ca1142 | ||
|
|
a928fbfafe | ||
|
|
3bf3ff1ee2 | ||
|
|
9647cc6cf2 | ||
|
|
df2d8925ed | ||
|
|
7a945daefc | ||
|
|
409e77cf2f | ||
|
|
552f99a63b | ||
|
|
0842311451 | ||
|
|
4d4b498636 | ||
|
|
d08cc12240 | ||
|
|
237442872e | ||
|
|
16983826fb | ||
|
|
e60ff6b777 | ||
|
|
3a0ef3760c | ||
|
|
bc0bc78219 | ||
|
|
d3137505a1 | ||
|
|
a2acd336eb | ||
|
|
6e4750336b | ||
|
|
ddefeeaf02 | ||
|
|
250e0a930d | ||
|
|
51c6d60760 | ||
|
|
db513b21f8 | ||
|
|
ab336678ce | ||
|
|
3eea4d6945 | ||
|
|
d091d3fd17 | ||
|
|
fc71f484ad | ||
|
|
bd772bf900 | ||
|
|
14db654681 | ||
|
|
a85b96ea89 | ||
|
|
c2b5e876bc | ||
|
|
91c02dc0b3 | ||
|
|
f78ec830b5 | ||
|
|
9f0e508ab3 | ||
|
|
4ca50d750b | ||
|
|
07c1b1b7f3 | ||
|
|
3e95dd52cf | ||
|
|
80ef2f6b0e | ||
|
|
53a8cda310 | ||
|
|
63de49104c | ||
|
|
8aa80bcb84 | ||
|
|
95115a7c5e | ||
|
|
ce2967fd02 | ||
|
|
399fb87d20 | ||
|
|
c4bd5ac5df | ||
|
|
123c2d6c02 | ||
|
|
6954e9c984 | ||
|
|
fc573e4e48 | ||
|
|
0dbcfdc5ac | ||
|
|
4b8d4b4792 | ||
|
|
d798da33ef | ||
|
|
d99517c8d1 | ||
|
|
0787adcb8e | ||
|
|
f848561d25 | ||
|
|
efbddc2486 | ||
|
|
e6a138d8f2 | ||
|
|
5b7a578307 | ||
|
|
737738de93 | ||
|
|
eb3951ce13 | ||
|
|
c2b7d9a257 | ||
|
|
4738aa2771 | ||
|
|
29ac0af55e | ||
|
|
96bc64c456 | ||
|
|
0369deb72d | ||
|
|
6e53990845 | ||
|
|
feb262644e | ||
|
|
abd679820f | ||
|
|
cd3ce848d1 | ||
|
|
63ba393c12 | ||
|
|
23fdf8c457 | ||
|
|
304ad4e3db | ||
|
|
ec58ab07b6 | ||
|
|
1ba4047b1b | ||
|
|
0bab8adc41 | ||
|
|
17e09c601e | ||
|
|
1aca5fb6ff | ||
|
|
7860d690fa | ||
|
|
6d01c99d38 | ||
|
|
ddb645aeea | ||
|
|
f08e4b41c4 | ||
|
|
1e23624955 | ||
|
|
ee951a7448 | ||
|
|
9935ba370d | ||
|
|
e815cce143 | ||
|
|
cea1032180 | ||
|
|
5695e1d9c8 | ||
|
|
fd317afd01 | ||
|
|
ccddd2a96f | ||
|
|
513d3034d8 | ||
|
|
51495187fa | ||
|
|
2bd53f7b9f | ||
|
|
06d9c48ed4 | ||
|
|
1155d18b7f | ||
|
|
6e14592c78 | ||
|
|
55feaf2d2c | ||
|
|
c487036c8b | ||
|
|
853ebf8c70 | ||
|
|
1c695c1cf9 | ||
|
|
bd5687d169 | ||
|
|
b384f71b64 | ||
|
|
10dd5278e7 | ||
|
|
befa6527e4 | ||
|
|
00497630cb | ||
|
|
95cd457de1 | ||
|
|
7518c9e3e0 | ||
|
|
6a999835e2 | ||
|
|
41d099c1be | ||
|
|
ff306ce2c5 | ||
|
|
c7abc82055 | ||
|
|
041d91dd3c | ||
|
|
387f56ed9b | ||
|
|
3181323c1f | ||
|
|
ecf84150c1 | ||
|
|
5b5025c776 | ||
|
|
e47dd3058b | ||
|
|
71f1dcd475 | ||
|
|
941856932c | ||
|
|
c51fde52e7 | ||
|
|
c5362e3bde | ||
|
|
a113703451 | ||
|
|
55ecb918e9 | ||
|
|
3a870e2f8b | ||
|
|
734231a4f1 | ||
|
|
223d6b29f4 | ||
|
|
4f41ec0a97 | ||
|
|
347a53297d | ||
|
|
820766abcb | ||
|
|
4974fa1fed | ||
|
|
7e829fa204 | ||
|
|
f6c7caa48d | ||
|
|
0dd9d252fd | ||
|
|
39f67a241c | ||
|
|
5706b08366 | ||
|
|
81de9695e2 | ||
|
|
589fb25fe3 | ||
|
|
61e5c6b468 | ||
|
|
087ceb3687 | ||
|
|
0a2cd208b2 | ||
|
|
678a936897 | ||
|
|
7c72ca089b | ||
|
|
21530f315f | ||
|
|
7274905a92 | ||
|
|
6c5cff6162 | ||
|
|
cf6b6c129a | ||
|
|
74491d16ae | ||
|
|
c1ab6e4eb4 | ||
|
|
18c9ae235a | ||
|
|
5c69d5fb88 | ||
|
|
90f0bda879 | ||
|
|
1b5c4a21bb | ||
|
|
08ee37112f | ||
|
|
cfbc88d3d6 | ||
|
|
79f5529a5a | ||
|
|
11ed0abd18 | ||
|
|
01830d9910 | ||
|
|
0f573805f2 | ||
|
|
93b1d81a48 | ||
|
|
e28d13b910 | ||
|
|
8731e343c4 | ||
|
|
605eca8cd7 | ||
|
|
5a8ddf5e4a | ||
|
|
f6d5d575fc | ||
|
|
d5c344e3ac | ||
|
|
18ba326cea | ||
|
|
1a1473d3ba | ||
|
|
72804a09ec | ||
|
|
c1ce0a514c | ||
|
|
bd479312b5 | ||
|
|
469da540d2 | ||
|
|
69edaa974f | ||
|
|
ff56963040 | ||
|
|
266aeaef50 | ||
|
|
fc660cfb1f | ||
|
|
27d343bdea | ||
|
|
a04b0da54a | ||
|
|
b15a6bfa98 | ||
|
|
dcc638c12f | ||
|
|
84ea96a5ad | ||
|
|
ae1bf85740 | ||
|
|
1612d713c9 | ||
|
|
6a4a8af731 | ||
|
|
e18375ca6d | ||
|
|
e537e4538a | ||
|
|
1ae97f5477 | ||
|
|
cc0083c6e5 | ||
|
|
43e6ed2da9 | ||
|
|
27bb3a948b | ||
|
|
7c155d307b | ||
|
|
d789beddd0 | ||
|
|
f790148ad3 | ||
|
|
a643abe293 | ||
|
|
099b08f009 | ||
|
|
35ddf6790e | ||
|
|
6502fdb1f5 | ||
|
|
b5cd3bf0af | ||
|
|
8183648902 | ||
|
|
0e1159b01e | ||
|
|
625ef3da8a | ||
|
|
10c7d9a6e1 | ||
|
|
85952ce6b7 | ||
|
|
bf9ce68d8b | ||
|
|
08c5992447 | ||
|
|
dfc7f7c827 | ||
|
|
efdbbc6098 | ||
|
|
185cf90d4c | ||
|
|
4db4790270 | ||
|
|
be3b890e2f | ||
|
|
4536f96493 | ||
|
|
a598c3e7a8 | ||
|
|
d9f5ee9d76 | ||
|
|
a4ced609cd | ||
|
|
673a4e6805 | ||
|
|
d017ccfbd4 | ||
|
|
1f52ed2e83 | ||
|
|
08e83f616c | ||
|
|
51edc4652e | ||
|
|
a3c6f38642 | ||
|
|
a1db53f50b | ||
|
|
9e1046fde3 | ||
|
|
17173f72e0 | ||
|
|
f60a99c357 | ||
|
|
1d763f1bc9 | ||
|
|
248b94c296 | ||
|
|
f52447ff58 | ||
|
|
0cbacbb959 | ||
|
|
a01edecaef | ||
|
|
779756f1ab | ||
|
|
723fedc066 | ||
|
|
a83bb23540 | ||
|
|
5d68a5133e | ||
|
|
8ca629151d | ||
|
|
693965af28 | ||
|
|
e645a350f2 | ||
|
|
85e9808550 | ||
|
|
0ce1c4565e | ||
|
|
478964ad30 | ||
|
|
74a04e3b35 | ||
|
|
a48992ed9d | ||
|
|
9a6ea8c9bb | ||
|
|
51b05cb128 | ||
|
|
de33d6d44c | ||
|
|
3d5cc98df5 | ||
|
|
13f3b54393 | ||
|
|
f17f7b2272 | ||
|
|
f61dc7197a | ||
|
|
0534508bc3 | ||
|
|
446c7ffd6a | ||
|
|
79e6216669 | ||
|
|
5047e48de5 | ||
|
|
bd48112bf9 | ||
|
|
5dc100d900 | ||
|
|
9f2ecb67d4 | ||
|
|
5e4f45826e | ||
|
|
be6ff21184 | ||
|
|
5c660fbe7f | ||
|
|
108718f275 | ||
|
|
ab53a0b403 | ||
|
|
49b815bc98 | ||
|
|
c702814203 | ||
|
|
0c0172a0b6 | ||
|
|
a8266c22f6 | ||
|
|
532c7fbc8f | ||
|
|
23ed381859 | ||
|
|
1ad11b0c58 | ||
|
|
18cca916a0 | ||
|
|
97012082de | ||
|
|
423810cf61 | ||
|
|
a5159ce8e1 | ||
|
|
4dd3952c19 | ||
|
|
1e26b5c5f1 | ||
|
|
67897dfcc0 | ||
|
|
0100604798 | ||
|
|
47afe01721 | ||
|
|
a2e12b795f | ||
|
|
806ab3438e | ||
|
|
f4be90fdd0 | ||
|
|
dd46767ee3 | ||
|
|
a2c712e5b3 | ||
|
|
35f3a0077a | ||
|
|
bc4195942a | ||
|
|
03baca2ed7 | ||
|
|
54a9c31a1a | ||
|
|
db5073223d | ||
|
|
afd766999c | ||
|
|
0637490216 | ||
|
|
6a3ba87b22 | ||
|
|
20b287da52 | ||
|
|
18a378976b | ||
|
|
8e7af49206 | ||
|
|
edeab082d4 | ||
|
|
7b76baaacf | ||
|
|
053365cb67 | ||
|
|
8301120a95 | ||
|
|
f15f0a6226 | ||
|
|
0cfcadf5fa | ||
|
|
435c4acba6 | ||
|
|
edb913855d | ||
|
|
24739e1638 | ||
|
|
54b906addb | ||
|
|
4a7a8df8a4 | ||
|
|
f1dd62c936 | ||
|
|
80cc7b0d64 | ||
|
|
eb4fbf3c0b | ||
|
|
c1cf1206fc | ||
|
|
efebc02d24 | ||
|
|
21dca8c17f | ||
|
|
4eb9839f77 | ||
|
|
3b7906ea04 | ||
|
|
9d17858500 | ||
|
|
d5ceb5f465 | ||
|
|
7dd2a0bbb4 | ||
|
|
13284fb3b9 | ||
|
|
f42c5ec0ce | ||
|
|
6b269839cb | ||
|
|
2eb3e0a278 | ||
|
|
183a437678 | ||
|
|
116b8171f8 | ||
|
|
c8c723bf4a | ||
|
|
d01cf018ce | ||
|
|
c701ab0776 | ||
|
|
180269d6b0 | ||
|
|
645c604fd4 | ||
|
|
de210db90d | ||
|
|
beddf1c772 | ||
|
|
75e618ee4a | ||
|
|
d2a3ba182b | ||
|
|
427f78b14d | ||
|
|
febcf237ca | ||
|
|
5e158c3bd7 | ||
|
|
b4c9c86ba6 | ||
|
|
7c00853f5d | ||
|
|
a0fcb116f5 | ||
|
|
e46b33544d | ||
|
|
6b9c3ad4e7 | ||
|
|
dc12b9a197 | ||
|
|
d473f56c3a | ||
|
|
4138ab3d7d | ||
|
|
e18d1a451d | ||
|
|
a3048cd393 | ||
|
|
dd8fdc6c0a | ||
|
|
9099e4b709 | ||
|
|
52b176b9eb | ||
|
|
69fd70787c | ||
|
|
ff37aea9c8 | ||
|
|
85f73977bf | ||
|
|
2c04ed48c2 | ||
|
|
1228754280 | ||
|
|
a43ee054ad | ||
|
|
83bc714739 | ||
|
|
a08390c84a | ||
|
|
8b6eacecfe | ||
|
|
fb96787697 | ||
|
|
9cff77be62 | ||
|
|
0d1643da66 | ||
|
|
5e7027647a | ||
|
|
28f6f09e8f | ||
|
|
332af5d21b | ||
|
|
e187005130 | ||
|
|
0357386f7c | ||
|
|
47f8e5b8c6 | ||
|
|
e95c9d73a1 | ||
|
|
b7174070fe | ||
|
|
dd06a7b62c | ||
|
|
ff9d480b6e | ||
|
|
229ad9108b | ||
|
|
0e332d291a | ||
|
|
180904cdc2 | ||
|
|
0e83f7d807 | ||
|
|
5d7931fcaf | ||
|
|
2e906b0bf5 | ||
|
|
33ae6f12de | ||
|
|
f302c2e154 | ||
|
|
3ee2492382 | ||
|
|
4caed50018 | ||
|
|
aadb19a792 | ||
|
|
9f8211a873 | ||
|
|
d45fc05e5d | ||
|
|
955a3a054e | ||
|
|
60f265a5fa | ||
|
|
a2d82a1a7b | ||
|
|
0875d728e8 | ||
|
|
f3cf6b8b38 | ||
|
|
e4465cffb0 | ||
|
|
ca35d714dc | ||
|
|
c06e7348c4 | ||
|
|
60ac8a6ebd | ||
|
|
e3450baeb3 | ||
|
|
72661623f3 | ||
|
|
b4d97d9432 | ||
|
|
b40100f78b | ||
|
|
a343d2b42c | ||
|
|
d3d7e54cff | ||
|
|
6535bc3d5e | ||
|
|
f966fc8d84 | ||
|
|
8a20bbd943 | ||
|
|
cd0f6d85ba | ||
|
|
d51edbb3bb | ||
|
|
553e475cfb | ||
|
|
b9367446d9 | ||
|
|
82d9fccec8 | ||
|
|
cbbcfb7a3a | ||
|
|
1f862b27c1 | ||
|
|
883b03349e | ||
|
|
f740a6ba61 | ||
|
|
fb3e761a37 | ||
|
|
3c7411328d | ||
|
|
9c2bfdfead | ||
|
|
4f3bd1ff4a | ||
|
|
69d10489b8 | ||
|
|
df031b2222 | ||
|
|
850b9e5e3d | ||
|
|
a95a208e1b | ||
|
|
50ff3628f7 | ||
|
|
14d203055b | ||
|
|
4628e28592 | ||
|
|
7fb3d13733 | ||
|
|
11ff81f852 | ||
|
|
0f5af4b990 | ||
|
|
85420602e8 | ||
|
|
6ccf55b601 | ||
|
|
42c9e21d04 | ||
|
|
3030c300f2 | ||
|
|
48b969f3c3 | ||
|
|
bbb78aa5e6 | ||
|
|
31380bbef2 | ||
|
|
479a7d9162 | ||
|
|
6fe02f156a | ||
|
|
c4ed210fed | ||
|
|
ae686fab38 | ||
|
|
8edca9ed5d | ||
|
|
05bafd0db5 | ||
|
|
341d699240 | ||
|
|
552093d962 | ||
|
|
eb6063cc2d | ||
|
|
550ff4ff18 | ||
|
|
5383a8b77c | ||
|
|
86117091fe | ||
|
|
b113028a5f | ||
|
|
60a3f21857 | ||
|
|
65a2ea3935 | ||
|
|
6ecddfc6c0 | ||
|
|
d65d48db48 | ||
|
|
f509b26800 | ||
|
|
43fb6fe6e5 | ||
|
|
9d2d8684b6 | ||
|
|
1689925508 | ||
|
|
4d249553bf | ||
|
|
43ea1044cd | ||
|
|
cc4a301dc1 | ||
|
|
ab67eea36e | ||
|
|
fa326eba6f | ||
|
|
c30ebdf287 | ||
|
|
835bcb7207 | ||
|
|
777424ad18 | ||
|
|
4985e7e96d | ||
|
|
ca1e64ec10 | ||
|
|
26029508c6 | ||
|
|
118259a96b | ||
|
|
35e8dcf2bc | ||
|
|
359a5d01e6 | ||
|
|
1c2acbb57f | ||
|
|
01a702c529 | ||
|
|
1ee584c5a1 | ||
|
|
fc10bd7749 | ||
|
|
f2568092a7 | ||
|
|
6b5d5a6334 | ||
|
|
195ed57025 | ||
|
|
008b4a134b | ||
|
|
1b9bfb5b62 | ||
|
|
edeaa1333b | ||
|
|
e678b52a7e | ||
|
|
b549db58e4 | ||
|
|
c14059f66a | ||
|
|
11f69daaec | ||
|
|
c0120c0f17 | ||
|
|
c1a5f9adf1 | ||
|
|
5087f27546 | ||
|
|
efbff9e217 | ||
|
|
20ea83ae93 | ||
|
|
05daeb561c | ||
|
|
bfff001752 | ||
|
|
c3a45a1584 | ||
|
|
b09a92a264 | ||
|
|
44a792583c | ||
|
|
71c8267dea | ||
|
|
b6688f56b5 | ||
|
|
f703164098 | ||
|
|
6a6b27e905 | ||
|
|
731a46c612 | ||
|
|
92a8078322 | ||
|
|
ba2d77f0bb | ||
|
|
3d21c15281 | ||
|
|
cb4b20c057 | ||
|
|
2af2767699 | ||
|
|
e4bb19b98a | ||
|
|
7e784c9509 | ||
|
|
3dd27797dc | ||
|
|
5e059272dc | ||
|
|
0a9aeca3bc | ||
|
|
11d42e0f93 | ||
|
|
85d8658037 | ||
|
|
dfa29950ef | ||
|
|
b7366a8704 | ||
|
|
57416103c3 | ||
|
|
72bd3731de | ||
|
|
9fab20ca6c | ||
|
|
8b4453f32d | ||
|
|
f4b77e6b03 | ||
|
|
c3da2fca9b | ||
|
|
c0d68c5740 | ||
|
|
5398564aec | ||
|
|
904dc80aab | ||
|
|
516de20148 | ||
|
|
be088709af | ||
|
|
fd4f5057b3 | ||
|
|
686d5e8b03 | ||
|
|
c371ff5504 | ||
|
|
9862dca4aa | ||
|
|
716321b37b | ||
|
|
b3ed8bad9c | ||
|
|
0a170f5c29 | ||
|
|
ec0fba7913 | ||
|
|
2630c2baf1 | ||
|
|
a01865b19b | ||
|
|
00f6115a93 | ||
|
|
a466202bac | ||
|
|
bb12ef24f8 | ||
|
|
c862c6de0f | ||
|
|
a1cb3ec8d5 | ||
|
|
187d4cd02d | ||
|
|
20a0f9b026 | ||
|
|
f9c0ed6ad4 | ||
|
|
27652e7191 | ||
|
|
828665cb29 | ||
|
|
fa784c83bf | ||
|
|
2d83176892 | ||
|
|
776758c7e8 | ||
|
|
d72afe9b92 | ||
|
|
dee61b5499 | ||
|
|
9f73d0a7fb | ||
|
|
bc804c9e56 | ||
|
|
35f450aee7 | ||
|
|
5803b4ca27 | ||
|
|
7bccd62a4f | ||
|
|
335838f2b2 | ||
|
|
204d8cc7eb | ||
|
|
61f5d4b172 | ||
|
|
3d829c6ce8 | ||
|
|
5d9852b72c | ||
|
|
f561ece9d1 | ||
|
|
66eabd3bd6 | ||
|
|
b2f92acbf6 | ||
|
|
6f30ecb365 | ||
|
|
32a89d3895 | ||
|
|
97bf958b74 | ||
|
|
30f8afca85 | ||
|
|
ed88a8e3e3 | ||
|
|
421f690f42 | ||
|
|
a330e8afb2 | ||
|
|
d8e5c9f033 | ||
|
|
209646e012 | ||
|
|
7d518df13c | ||
|
|
ca603f41db | ||
|
|
7bb18f6fad | ||
|
|
1a0e2031d2 | ||
|
|
4f83d69205 | ||
|
|
cfafd90f15 | ||
|
|
a94f416b3c | ||
|
|
fd47e2de29 | ||
|
|
abbc403f73 | ||
|
|
b41c536865 | ||
|
|
bee7314dd7 | ||
|
|
d25407e3b4 | ||
|
|
ad697369ef | ||
|
|
edbdb17a2f | ||
|
|
9d2e2a1ea2 | ||
|
|
6df0597c5e | ||
|
|
093eb28463 | ||
|
|
7d0c279f5b | ||
|
|
d98a6a09bb | ||
|
|
02cf7b9d66 | ||
|
|
9253b783dd | ||
|
|
2ed82be809 | ||
|
|
1c1499dec8 | ||
|
|
f7f151d2a9 | ||
|
|
13f29ee3ce | ||
|
|
ce68f52ca0 | ||
|
|
33172767a6 | ||
|
|
649b3839d2 | ||
|
|
666fb4c194 | ||
|
|
9301497a4a | ||
|
|
6956e21caf | ||
|
|
71dec5746e | ||
|
|
0ea8f4c259 | ||
|
|
8602814dc3 | ||
|
|
20e60edbc6 | ||
|
|
88f59ad1eb | ||
|
|
668a899260 | ||
|
|
75ae85a5d4 | ||
|
|
abc1b4e1b2 | ||
|
|
fa194f0cef | ||
|
|
b3fbd89456 | ||
|
|
5334a4cbe0 | ||
|
|
8f2adf0a50 | ||
|
|
62dfd7cef0 | ||
|
|
a8321e8cd3 | ||
|
|
0119552336 | ||
|
|
033abc64c8 | ||
|
|
ef8014bc6d | ||
|
|
96a880b5ae | ||
|
|
bfedcde978 | ||
|
|
badad70984 | ||
|
|
7611188535 | ||
|
|
31f2cc1fdc | ||
|
|
187e646fa0 | ||
|
|
b2721db8e0 | ||
|
|
fd9f521c60 | ||
|
|
edd6fbe35f | ||
|
|
839c9c9884 | ||
|
|
3a1fe992d6 | ||
|
|
9899f6d1f8 | ||
|
|
446a464b3d | ||
|
|
6a347799c7 | ||
|
|
2dae89e41c | ||
|
|
e8119ba80d | ||
|
|
a5ecad8fae | ||
|
|
1708a4c831 | ||
|
|
a237078b68 | ||
|
|
4ef63d026e | ||
|
|
b8ae3cdd3f | ||
|
|
46d855ce0f | ||
|
|
a3306bbb5a | ||
|
|
1428a5e7e2 | ||
|
|
427940b3be | ||
|
|
7a3e7dc631 | ||
|
|
b38bb40a5d | ||
|
|
b2e1e2e89a | ||
|
|
02fcc42395 | ||
|
|
3accc406a7 | ||
|
|
1c238b7ce4 | ||
|
|
45770173c4 | ||
|
|
3e5f6abdad | ||
|
|
4117c6127e | ||
|
|
aae1fad7ab | ||
|
|
ada65b5ce2 | ||
|
|
14c0c65e17 | ||
|
|
0201aa9bd1 | ||
|
|
dca530f2f2 | ||
|
|
c9f9668e52 | ||
|
|
4f636b7cfb | ||
|
|
34a04c0059 | ||
|
|
00ee58d3fd | ||
|
|
ecb3c4f4f3 | ||
|
|
9dace592c0 | ||
|
|
5d73221b06 | ||
|
|
d50958c9ee | ||
|
|
52bb005792 | ||
|
|
3121aa7164 | ||
|
|
87c54f07c6 | ||
|
|
f1d4a686b1 | ||
|
|
56ac037128 | ||
|
|
e977045d5f | ||
|
|
3301b106ab | ||
|
|
e645f55191 | ||
|
|
278d25c803 |
28
.gitattributes
vendored
28
.gitattributes
vendored
@@ -1,17 +1,17 @@
|
||||
src/static/fontawesome/* linguist-vendored
|
||||
src/static/lightbox/* linguist-vendored
|
||||
src/static/typeahead/* linguist-vendored
|
||||
src/static/moment/* linguist-vendored
|
||||
src/static/datetimepicker/* linguist-vendored
|
||||
src/static/colorpicker/* linguist-vendored
|
||||
src/static/fileupload/* linguist-vendored
|
||||
src/static/vuejs/* linguist-vendored
|
||||
src/static/select2/* linguist-vendored
|
||||
src/static/charts/* linguist-vendored
|
||||
src/static/rrule/* linguist-vendored
|
||||
src/static/iframeresizer/* linguist-vendored
|
||||
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/fabric.* linguist-vendored
|
||||
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/pdf.* linguist-vendored
|
||||
src/pretix/static/fontawesome/* linguist-vendored
|
||||
src/pretix/static/lightbox/* linguist-vendored
|
||||
src/pretix/static/typeahead/* linguist-vendored
|
||||
src/pretix/static/moment/* linguist-vendored
|
||||
src/pretix/static/datetimepicker/* linguist-vendored
|
||||
src/pretix/static/colorpicker/* linguist-vendored
|
||||
src/pretix/static/fileupload/* linguist-vendored
|
||||
src/pretix/static/vuejs/* linguist-vendored
|
||||
src/pretix/static/select2/* linguist-vendored
|
||||
src/pretix/static/charts/* linguist-vendored
|
||||
src/pretix/static/rrule/* linguist-vendored
|
||||
src/pretix/static/iframeresizer/* linguist-vendored
|
||||
src/pretix/static/pdfjs/* linguist-vendored
|
||||
src/pretix/static/fabric/* linguist-vendored
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.eot binary
|
||||
|
||||
@@ -17,7 +17,7 @@ pypi:
|
||||
- virtualenv env
|
||||
- source env/bin/activate
|
||||
- pip install -U pip wheel setuptools
|
||||
- XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
- XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt
|
||||
- cd src
|
||||
- python setup.py sdist
|
||||
- pip install dist/pretix-*.tar.gz
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
-r src/requirements/py34.txt
|
||||
-r doc/requirements.txt
|
||||
|
||||
20
.travis.sh
20
.travis.sh
@@ -11,21 +11,20 @@ fi
|
||||
|
||||
if [ "$PRETIX_CONFIG_FILE" == "tests/travis_postgres.cfg" ]; then
|
||||
psql -c 'create database travis_ci_test;' -U postgres
|
||||
pip3 install -Ur src/requirements/postgres.txt
|
||||
fi
|
||||
|
||||
if [ "$1" == "style" ]; then
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt
|
||||
cd src
|
||||
flake8 .
|
||||
isort -c -rc -df .
|
||||
fi
|
||||
if [ "$1" == "doctests" ]; then
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt -r src/requirements/py34.txt
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt
|
||||
cd doc
|
||||
make doctest
|
||||
fi
|
||||
if [ "$1" == "spelling" ]; then
|
||||
if [ "$1" == "doc-spelling" ]; then
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt
|
||||
cd doc
|
||||
make spelling
|
||||
@@ -33,22 +32,27 @@ if [ "$1" == "spelling" ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ "$1" == "translation-spelling" ]; then
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements/dev.txt
|
||||
cd src
|
||||
potypo
|
||||
fi
|
||||
if [ "$1" == "tests" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
py.test --reruns 5 tests
|
||||
py.test --reruns 5 -n 3 tests
|
||||
fi
|
||||
if [ "$1" == "tests-cov" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
coverage run -m py.test --reruns 5 tests && codecov
|
||||
fi
|
||||
if [ "$1" == "plugins" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
cd src
|
||||
python setup.py develop
|
||||
make all compress
|
||||
|
||||
26
.travis.yml
26
.travis.yml
@@ -1,7 +1,7 @@
|
||||
language: python
|
||||
sudo: false
|
||||
install:
|
||||
- pip install -U pip wheel setuptools==28.6.1
|
||||
- pip install -U pip wheel setuptools
|
||||
script:
|
||||
- bash .travis.sh $JOB
|
||||
cache:
|
||||
@@ -15,34 +15,32 @@ matrix:
|
||||
- python: 3.6
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
|
||||
- python: 3.6
|
||||
env: JOB=tests-cov
|
||||
env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.6
|
||||
env: JOB=style
|
||||
- python: 3.4
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
|
||||
- python: 3.4
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.6
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.4
|
||||
- python: 3.6
|
||||
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
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.6
|
||||
env: JOB=plugins
|
||||
- python: 3.6
|
||||
env: JOB=spelling
|
||||
env: JOB=doc-spelling
|
||||
- python: 3.6
|
||||
env: JOB=translation-spelling
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
mariadb: '10.3'
|
||||
apt:
|
||||
packages:
|
||||
- enchant
|
||||
- myspell-de-de
|
||||
- aspell-en
|
||||
- sqlite3
|
||||
sources:
|
||||
- travis-ci/sqlite3
|
||||
branches:
|
||||
except:
|
||||
- /^weblate-.*/
|
||||
|
||||
43
Dockerfile
43
Dockerfile
@@ -1,10 +1,26 @@
|
||||
FROM python:3.6
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y git libxml2-dev libxslt1-dev python-dev python-virtualenv locales \
|
||||
libffi-dev build-essential python3-dev zlib1g-dev libssl-dev gettext libpq-dev \
|
||||
libmysqlclient-dev libmemcached-dev libjpeg-dev supervisor nginx sudo \
|
||||
--no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
default-libmysqlclient-dev \
|
||||
gettext \
|
||||
git \
|
||||
libffi-dev \
|
||||
libjpeg-dev \
|
||||
libmemcached-dev \
|
||||
libpq-dev \
|
||||
libssl-dev \
|
||||
libxml2-dev \
|
||||
libxslt1-dev \
|
||||
locales \
|
||||
nginx \
|
||||
python-dev \
|
||||
python-virtualenv \
|
||||
python3-dev \
|
||||
sudo \
|
||||
supervisor \
|
||||
zlib1g-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
dpkg-reconfigure locales && \
|
||||
@@ -19,6 +35,22 @@ RUN apt-get update && \
|
||||
ENV LC_ALL=C.UTF-8 \
|
||||
DJANGO_SETTINGS_MODULE=production_settings
|
||||
|
||||
# To copy only the requirements files needed to install from PIP
|
||||
COPY src/requirements /pretix/src/requirements
|
||||
COPY src/requirements.txt /pretix/src
|
||||
RUN pip3 install -U \
|
||||
pip \
|
||||
setuptools \
|
||||
wheel && \
|
||||
cd /pretix/src && \
|
||||
pip3 install \
|
||||
-r requirements.txt \
|
||||
-r requirements/memcached.txt \
|
||||
-r requirements/mysql.txt \
|
||||
-r requirements/redis.txt \
|
||||
gunicorn && \
|
||||
rm -rf ~/.cache/pip
|
||||
|
||||
COPY deployment/docker/pretix.bash /usr/local/bin/pretix
|
||||
COPY deployment/docker/supervisord.conf /etc/supervisord.conf
|
||||
COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
|
||||
@@ -27,11 +59,8 @@ COPY src /pretix/src
|
||||
|
||||
RUN chmod +x /usr/local/bin/pretix && \
|
||||
rm /etc/nginx/sites-enabled/default && \
|
||||
pip3 install -U pip wheel setuptools && \
|
||||
cd /pretix/src && \
|
||||
rm -f pretix.cfg && \
|
||||
pip3 install -r requirements.txt -r requirements/mysql.txt -r requirements/postgres.txt \
|
||||
-r requirements/memcached.txt -r requirements/redis.txt gunicorn && \
|
||||
mkdir -p data && \
|
||||
chown -R pretixuser:pretixuser /pretix /data data && \
|
||||
sudo -u pretixuser make production
|
||||
|
||||
@@ -3,7 +3,7 @@ cd /pretix/src
|
||||
export DJANGO_SETTINGS_MODULE=production_settings
|
||||
export DATA_DIR=/data/
|
||||
export HOME=/pretix
|
||||
NUM_WORKERS=10
|
||||
export NUM_WORKERS=$((2 * $(nproc --all)))
|
||||
|
||||
if [ ! -d /data/logs ]; then
|
||||
mkdir /data/logs;
|
||||
|
||||
@@ -53,6 +53,10 @@ Example::
|
||||
A comma-separated list of plugins that are enabled by default for all new events.
|
||||
Defaults to ``pretix.plugins.sendmail,pretix.plugins.statistics``.
|
||||
|
||||
``plugins_exclude``
|
||||
A comma-separated list of plugins that are not available even though they are installed.
|
||||
Defaults to an empty string.
|
||||
|
||||
``cookie_domain``
|
||||
The cookie domain to be set. Defaults to ``None``.
|
||||
|
||||
@@ -121,6 +125,23 @@ Example::
|
||||
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
|
||||
turns on some optimizations/special case handlers. Default: ``False``
|
||||
|
||||
Database replica settings
|
||||
-------------------------
|
||||
|
||||
If you use a replicated database setup, pretix expects that the default database connection always points to the primary database node.
|
||||
Routing read queries to a replica on database layer is **strongly** discouraged since this can lead to inaccurate such as more tickets
|
||||
being sold than are actually available.
|
||||
|
||||
However, pretix can still make use of a database replica to keep some expensive queries with that can tolerate some latency from your
|
||||
primary database, such as backend search queries. The ``replica`` configuration section can have the same settings as the ``database``
|
||||
section (except for the ``backend`` setting) and will default back to the ``database`` settings for all values that are not given. This
|
||||
way, you just need to specify the settings that are different for the replica.
|
||||
|
||||
Example::
|
||||
|
||||
[replica]
|
||||
host=192.168.0.2
|
||||
|
||||
URLs
|
||||
----
|
||||
|
||||
@@ -291,5 +312,13 @@ various places like order codes, secrets in the ticket QR codes, etc. Example::
|
||||
; Voucher code needs to be < 255 characters, default is 16
|
||||
voucher_code=16
|
||||
|
||||
External tools
|
||||
--------------
|
||||
|
||||
pretix can make use of some external tools if they are installed. Currently, they are all optional. Example::
|
||||
|
||||
[tools]
|
||||
pdftk=/usr/bin/pdftk
|
||||
|
||||
.. _Python documentation: https://docs.python.org/3/library/configparser.html?highlight=configparser#supported-ini-file-structure
|
||||
.. _Celery documentation: http://docs.celeryproject.org/en/latest/userguide/configuration.html
|
||||
|
||||
37
doc/admin/installation/dev_version.rst
Normal file
37
doc/admin/installation/dev_version.rst
Normal file
@@ -0,0 +1,37 @@
|
||||
.. highlight:: none
|
||||
|
||||
Installing a development version
|
||||
================================
|
||||
|
||||
If you want to use a feature of pretix that is not yet contained in the last monthly release, you can also
|
||||
install a development version with pretix.
|
||||
|
||||
.. warning:: When in production, we strongly recommend only installing released versions. Development versions might
|
||||
be broken, incompatible to plugins, or in rare cases incompatible to upgrade later on.
|
||||
|
||||
|
||||
Manual installation
|
||||
-------------------
|
||||
|
||||
You can use ``pip`` to update pretix directly to the development branch. Then, upgrade as usual::
|
||||
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install -U "git+https://github.com/pretix/pretix.git#egg=pretix&subdirectory=src"
|
||||
(venv)$ python -m pretix migrate
|
||||
(venv)$ python -m pretix rebuild
|
||||
(venv)$ python -m pretix updatestyles
|
||||
# systemctl restart pretix-web pretix-worker
|
||||
|
||||
Docker installation
|
||||
-------------------
|
||||
|
||||
To use the latest development version with Docker, first pull it from Docker Hub::
|
||||
|
||||
$ docker pull pretix/standalone:latest
|
||||
|
||||
|
||||
Then change your ``/etc/systemd/system/pretix.service`` file to use the ``:latest`` tag instead of ``:stable`` as well
|
||||
and upgrade as usual::
|
||||
|
||||
$ systemctl restart pretix.service
|
||||
$ docker exec -it pretix.service pretix upgrade
|
||||
@@ -26,7 +26,7 @@ installation guides):
|
||||
* `Docker`_
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `MySQL`_ or `PostgreSQL`_ database server
|
||||
* A `PostgreSQL`_, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `redis`_ server
|
||||
|
||||
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
||||
@@ -36,6 +36,9 @@ Linux and firewalls, we recommend that you start with `ufw`_.
|
||||
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
|
||||
installations except for evaluation purposes.
|
||||
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
On this guide
|
||||
-------------
|
||||
|
||||
@@ -58,7 +61,7 @@ Next, we need a database and a database user. We can create these with any kind
|
||||
our database's shell, e.g. for MySQL::
|
||||
|
||||
$ mysql -u root -p
|
||||
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;
|
||||
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;
|
||||
|
||||
|
||||
84
doc/admin/installation/enterprise.rst
Normal file
84
doc/admin/installation/enterprise.rst
Normal file
@@ -0,0 +1,84 @@
|
||||
.. highlight:: none
|
||||
|
||||
Installing pretix Enterprise plugins
|
||||
====================================
|
||||
|
||||
If you want to use a feature of pretix that is part of our commercial offering pretix Enterprise, you need to follow
|
||||
some extra steps. Installation works similar to normal pretix plugins, but involves a few extra steps.
|
||||
|
||||
Buying the license
|
||||
------------------
|
||||
|
||||
To obtain a license, please get in touch at sales@pretix.eu. Please let us know how many tickets you roughly intend
|
||||
to sell per year and how many servers you want to use the plugin on. We recommend having a look at our `price list`_
|
||||
first.
|
||||
|
||||
|
||||
Manual installation
|
||||
-------------------
|
||||
|
||||
First, generate an SSH key for the system user that you install pretix as. In our tutorial, that would be the user
|
||||
``pretix``. Choose an empty passphrase::
|
||||
|
||||
# su pretix
|
||||
$ ssh-keygen
|
||||
Generating public/private rsa key pair.
|
||||
Enter file in which to save the key (/var/pretix/.ssh/id_rsa):
|
||||
Enter passphrase (empty for no passphrase):
|
||||
Enter same passphrase again:
|
||||
Your identification has been saved in /var/pretix/.ssh/id_rsa.
|
||||
Your public key has been saved in /var/pretix/.ssh/id_rsa.pub.
|
||||
|
||||
Next, send the content of the *public* key to your sales representative at pretix::
|
||||
|
||||
$ cat /var/pretix/.ssh/id_rsa.pub
|
||||
ssh-rsa AAAAB3N...744HZawHlD pretix@foo
|
||||
|
||||
After we configured your key in our system, you can install the plugin directly using ``pip`` from the URL we told
|
||||
you, for example::
|
||||
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install -U "git+ssh://git@code.rami.io:10022/pretix/pretix-slack.git@stable#egg=pretix-slack"
|
||||
(venv)$ python -m pretix migrate
|
||||
(venv)$ python -m pretix rebuild
|
||||
# systemctl restart pretix-web pretix-worker
|
||||
|
||||
Docker installation
|
||||
-------------------
|
||||
|
||||
To install a plugin, you need to build your own docker image. To do so, create a new directory to work in. As a first
|
||||
step, generate a new SSH key in that directory to use for authentication with us::
|
||||
|
||||
$ cd /home/me/mypretixdocker
|
||||
$ ssh-keygen -N "" -f id_pretix_enterprise
|
||||
|
||||
Next, send the content of the *public* key to your sales representative at pretix::
|
||||
|
||||
$ cat id_pretix_enterprise.pub
|
||||
ssh-rsa AAAAB3N...744HZawHlD pretix@foo
|
||||
|
||||
After we configured your key in our system, you can add a ``Dockerfile`` in your directory that includes the newly
|
||||
generated key and installs the plugin from the URL we told you::
|
||||
|
||||
FROM pretix/standalone:stable
|
||||
USER root
|
||||
COPY id_pretix_enterprise /root/.ssh/id_rsa
|
||||
COPY id_pretix_enterprise.pub /root/.ssh/id_rsa.pub
|
||||
RUN chmod -R 0600 /root/.ssh && \
|
||||
mkdir -p /etc/ssh && \
|
||||
ssh-keyscan -t rsa -p 10022 code.rami.io >> /root/.ssh/known_hosts && \
|
||||
echo StrictHostKeyChecking=no >> /root/.ssh/config && \
|
||||
pip3 install -Ue "git+ssh://git@code.rami.io:10022/pretix/pretix-slack.git@stable#egg=pretix-slack" && \
|
||||
cd /pretix/src && \
|
||||
sudo -u pretixuser make production
|
||||
USER pretixuser
|
||||
|
||||
Then, build the image for docker::
|
||||
|
||||
$ docker build -t mypretix
|
||||
|
||||
You can now use that image ``mypretix`` instead of ``pretix/standalone:stable`` in your ``/etc/systemd/system/pretix.service``
|
||||
service file. Be sure to re-build your custom image after you pulled ``pretix/standalone`` if you want to perform an
|
||||
update to a new version of pretix.
|
||||
|
||||
.. _price list: https://pretix.eu/about/en/pricing
|
||||
@@ -21,6 +21,9 @@ To use pretix, you will need the following things:
|
||||
|
||||
.. warning:: Do not ever use SQLite in production. It will break.
|
||||
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
* A **reverse proxy**. pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix
|
||||
is capable of doing this, having this handled by a proper web server like **nginx** or **Apache** will be much
|
||||
faster. Also, you need a proxying web server in front to provide SSL encryption.
|
||||
|
||||
@@ -10,3 +10,5 @@ for your needs.
|
||||
general
|
||||
docker_smallscale
|
||||
manual_smallscale
|
||||
dev_version
|
||||
enterprise
|
||||
|
||||
@@ -23,7 +23,7 @@ installation guides):
|
||||
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `MySQL`_ or `PostgreSQL`_ database server
|
||||
* A `PostgreSQL`_, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `redis`_ server
|
||||
|
||||
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
||||
@@ -33,6 +33,9 @@ Linux and firewalls, we recommend that you start with `ufw`_.
|
||||
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
|
||||
installations except for evaluation purposes.
|
||||
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
Unix user
|
||||
---------
|
||||
|
||||
@@ -50,7 +53,7 @@ Having the database server installed, we still need a database and a database us
|
||||
of database managing tool or directly on our database's shell, e.g. for MySQL::
|
||||
|
||||
$ mysql -u root -p
|
||||
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;
|
||||
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;
|
||||
|
||||
@@ -61,7 +64,7 @@ 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 \
|
||||
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
||||
gettext libpq-dev libmysqlclient-dev libjpeg-dev
|
||||
gettext libpq-dev libmysqlclient-dev libjpeg-dev libopenjp2-7-dev
|
||||
|
||||
Config file
|
||||
-----------
|
||||
@@ -121,8 +124,7 @@ command if you're running PostgreSQL::
|
||||
|
||||
(venv)$ pip3 install "pretix[mysql]" gunicorn
|
||||
|
||||
If you are running Python 3.4, you also need to ``pip3 install typing``. This is not required on 3.5 or newer.
|
||||
You can find out your Python version using ``python -V``.
|
||||
Note that you need Python 3.5 or newer. You can find out your Python version using ``python -V``.
|
||||
|
||||
We also need to create a data directory::
|
||||
|
||||
|
||||
9
doc/api/auth.rst
Normal file
9
doc/api/auth.rst
Normal file
@@ -0,0 +1,9 @@
|
||||
Authentication
|
||||
==============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
tokenauth
|
||||
oauth
|
||||
deviceauth
|
||||
137
doc/api/deviceauth.rst
Normal file
137
doc/api/deviceauth.rst
Normal file
@@ -0,0 +1,137 @@
|
||||
.. _`rest-deviceauth`:
|
||||
|
||||
Device authentication
|
||||
=====================
|
||||
|
||||
Initializing a new device
|
||||
-------------------------
|
||||
|
||||
Users can create new devices in the "Device" section of their organizer settings. When creating
|
||||
a new device, users can specify a list of events the device is allowed to access. After a new
|
||||
device is created, users will be presented initialization instructions, consisting of an URL
|
||||
and an initialization token. They will also be shown as a QR code with the following contents::
|
||||
|
||||
{"handshake_version": 1, "url": "https://pretix.eu", "token": "kpp4jn8g2ynzonp6"}
|
||||
|
||||
Your application should be able to scan a QR code of this type, or allow to enter the URL and the
|
||||
initialization token manually. The handshake version is not used for manual initialization. When a
|
||||
QR code is scanned with a higher handshake version than you support, you should reject the request
|
||||
and prompt the user to update the client application.
|
||||
|
||||
After your application received the token, you need to call the initialization endpoint to obtain
|
||||
a proper API token. At this point, you need to identify the name and version of your application,
|
||||
as well as the type of underlying hardware. Example:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/device/initialize HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"token": "kpp4jn8g2ynzonp6",
|
||||
"hardware_brand": "Samsung",
|
||||
"hardware_model": "Galaxy S",
|
||||
"software_brand": "pretixdroid",
|
||||
"software_version": "4.0.0"
|
||||
}
|
||||
|
||||
Every initialization token can only be used once. On success, you will receive a response containing
|
||||
information on your device as well as your API token:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"organizer": "foo",
|
||||
"device_id": 5,
|
||||
"unique_serial": "HHZ9LW9JWP390VFZ",
|
||||
"api_token": "1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd",
|
||||
"name": "Bar"
|
||||
}
|
||||
|
||||
Please make sure that you store this ``api_token`` value. We also recommend storing your device ID, your assigned
|
||||
``unique_serial``, and the ``organizer`` you have access to, but that's up to you.
|
||||
|
||||
In case of an error, the response will look like this:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 400 Bad Request
|
||||
Content-Type: application/json
|
||||
|
||||
{"token":["This initialization token has already been used."]}
|
||||
|
||||
|
||||
Performing API requests
|
||||
-----------------------
|
||||
|
||||
You need to include the API token with every request to pretix' API in the ``Authorization`` header
|
||||
like the following:
|
||||
|
||||
.. sourcecode:: http
|
||||
:emphasize-lines: 3
|
||||
|
||||
GET /api/v1/organizers/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
|
||||
|
||||
Updating the software version
|
||||
-----------------------------
|
||||
|
||||
If your application is updated, we ask you to tell the server about the new version in use. You can do this at the
|
||||
following endpoint:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/device/update HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Content-Type: application/json
|
||||
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
|
||||
|
||||
{
|
||||
"hardware_brand": "Samsung",
|
||||
"hardware_model": "Galaxy S",
|
||||
"software_brand": "pretixdroid",
|
||||
"software_version": "4.1.0"
|
||||
}
|
||||
|
||||
Creating a new API key
|
||||
----------------------
|
||||
|
||||
If you think your API key might have leaked or just want to be extra cautious, the API allows you to create a new key.
|
||||
The old API key will be invalid immediately. A request for a new key looks like this:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/device/roll HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
|
||||
|
||||
The response will look like the response to the initialization request.
|
||||
|
||||
Removing a device
|
||||
-----------------
|
||||
|
||||
If you want implement a way to to deprovision a device in your software, you can call the ``revoke`` endpoint to
|
||||
invalidate your API key. There is no way to reverse this operation.
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/device/revoke HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
|
||||
|
||||
This can also be done by the user through the web interface.
|
||||
|
||||
Permissions
|
||||
-----------
|
||||
|
||||
Device authentication is currently hardcoded to grant the following permissions:
|
||||
|
||||
* View event meta data and products etc.
|
||||
* View and change orders
|
||||
|
||||
Devices cannot change events or products and cannot access vouchers.
|
||||
@@ -6,43 +6,42 @@ with pretix' REST API, such as authentication, pagination and similar definition
|
||||
|
||||
.. _`rest-auth`:
|
||||
|
||||
Obtaining an API token
|
||||
----------------------
|
||||
|
||||
To authenticate your API requests, you need to obtain an API token. You can create a
|
||||
token in the pretix web interface on the level of organizer teams. Create a new team
|
||||
or choose an existing team that has the level of permissions the token should have and
|
||||
create a new token using the form below the list of team members:
|
||||
|
||||
.. image:: img/token_form.png
|
||||
:class: screenshot
|
||||
|
||||
You can enter a description for the token to distinguish from other tokens later on.
|
||||
Once you click "Add", you will be provided with an API token in the success message.
|
||||
Copy this token, as you won't be able to retrieve it again.
|
||||
|
||||
.. image:: img/token_success.png
|
||||
:class: screenshot
|
||||
|
||||
Authentication
|
||||
--------------
|
||||
|
||||
You need to include the API token with every request to pretix' API in the ``Authorization`` header
|
||||
like the following:
|
||||
To access the API, you need to present valid authentication credentials. pretix currently
|
||||
supports the following authorization schemes:
|
||||
|
||||
.. sourcecode:: http
|
||||
:emphasize-lines: 3
|
||||
* :ref:`rest-tokenauth`: This is the simplest way and recommended for server-side applications
|
||||
that interact with pretix without user interaction.
|
||||
* :ref:`rest-oauth`: This is the recommended way to use if you write a third-party application
|
||||
that users can connect with their pretix account. It provides the best user experience, but
|
||||
requires user interaction and slightly more implementation effort.
|
||||
* :ref:`rest-deviceauth`: This is the recommended way if you build apps or hardware devices that can
|
||||
connect to pretix, e.g. for processing check-ins or to sell tickets offline. It provides a way
|
||||
to uniquely identify devices and allows for a quick configuration flow inside your software.
|
||||
* Authentication using browser sessions: This is used by the pretix web interface and it is *not*
|
||||
officially supported for use by third-party applications. It might change or be removed at any
|
||||
time without prior notice. If you use it, you need to comply with Django's `CSRF policies`_.
|
||||
|
||||
GET /api/v1/organizers/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k
|
||||
Permissions
|
||||
-----------
|
||||
|
||||
.. note:: The API currently also supports authentication via browser sessions, i.e. the
|
||||
same way that you authenticate with pretix when using the browser interface.
|
||||
Using this type of authentication is *not* officially supported for use by
|
||||
third-party clients and might change or be removed at any time. We plan on
|
||||
adding OAuth2 support in the future for user-level authentication. If you want
|
||||
to use session authentication, be sure to comply with Django's `CSRF policies`_.
|
||||
The API follows pretix team based permissions model. Each organizer can have several teams
|
||||
each with it's own set of permissions. Each team can have any number of API keys attached.
|
||||
|
||||
To access a given endpoint the team the API key belongs to needs to have the corresponding
|
||||
permission for the organizer/event being accessed.
|
||||
|
||||
Possible permissions are:
|
||||
|
||||
* Can create events
|
||||
* Can change event settings
|
||||
* Can change product settings
|
||||
* Can view orders
|
||||
* Can change orders
|
||||
* Can view vouchers
|
||||
* Can change vouchers
|
||||
|
||||
Compatibility
|
||||
-------------
|
||||
@@ -90,6 +89,41 @@ respective page.
|
||||
The field ``results`` contains a list of objects representing the first results. For most
|
||||
objects, every page contains 50 results.
|
||||
|
||||
Conditional fetching
|
||||
--------------------
|
||||
|
||||
If you pull object lists from pretix' APIs regularly, we ask you to implement conditional fetching
|
||||
to avoid unnecessary data traffic. This is not supported on all resources and we currently implement
|
||||
two different mechanisms for different resources, which is necessary because we can only obtain best
|
||||
efficiency for resources that do not support deletion operations.
|
||||
|
||||
Object-level conditional fetching
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The :ref:`rest-orders` resource list contains an HTTP header called ``X-Page-Generated`` containing the
|
||||
current time on the server in ISO 8601 format. On your next request, you can pass this header
|
||||
(as is, without any modifications necessary) as the ``modified_since`` query parameter and you will receive
|
||||
a list containing only objects that have changed in the time since your last request.
|
||||
|
||||
List-level conditional fetching
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
If modification checks are not possible with this granularity, you can instead check for the full list.
|
||||
In this case, the list of objects may contain a regular HTTP header ``Last-Modified`` with the date of the
|
||||
last modification to any item of that resource. You can then pass this date back in your next request in the
|
||||
``If-Modified-Since`` header. If the any object has changed in the meantime, you will receive back a full list
|
||||
(if something it missing, this means the object has been deleted). If nothing happened, we'll send back a
|
||||
``304 Not Modified`` return code.
|
||||
|
||||
This is currently implemented on the following resources:
|
||||
|
||||
* :ref:`rest-categories`
|
||||
* :ref:`rest-items`
|
||||
* :ref:`rest-questions`
|
||||
* :ref:`rest-quotas`
|
||||
* :ref:`rest-subevents`
|
||||
* :ref:`rest-taxrules`
|
||||
|
||||
Errors
|
||||
------
|
||||
|
||||
@@ -114,6 +148,7 @@ Field specific input errors include the name of the offending fields as keys in
|
||||
|
||||
{"amount": ["A valid integer is required."], "description": ["This field may not be blank."]}
|
||||
|
||||
If you see errors of type ``429 Too Many Requests``, you should read our documentation on :ref:`rest-ratelimit`.
|
||||
|
||||
Data types
|
||||
----------
|
||||
@@ -146,4 +181,4 @@ 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.
|
||||
|
||||
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax
|
||||
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax
|
||||
|
||||
@@ -14,4 +14,7 @@ in functionality over time.
|
||||
:maxdepth: 2
|
||||
|
||||
fundamentals
|
||||
auth
|
||||
resources/index
|
||||
ratelimit
|
||||
webhooks
|
||||
|
||||
207
doc/api/oauth.rst
Normal file
207
doc/api/oauth.rst
Normal file
@@ -0,0 +1,207 @@
|
||||
.. _`rest-oauth`:
|
||||
|
||||
OAuth authentication / "Connect with pretix"
|
||||
============================================
|
||||
|
||||
In addition to static tokens, pretix supports `OAuth2`_-based authentication starting with
|
||||
pretix 1.16. This allows you to put a "Connect with pretix" button into your website or tool
|
||||
that allows the user to easily set up a connection between the two systems.
|
||||
|
||||
If you haven't worked with OAuth before, have a look at the `OAuth2 Simplified`_ tutorial.
|
||||
|
||||
Registering an application
|
||||
--------------------------
|
||||
|
||||
To use OAuth, you need to register your application with the pretix instance you want to connect to.
|
||||
In order to do this, log in to your pretix account and go to your user settings. Click on "Authorized applications"
|
||||
first and then on "Manage your own apps". From there, you can "Create a new application".
|
||||
|
||||
You should fill in a descriptive name of your application that allows users to recognize who you are. You also need to
|
||||
give a list of fully-qualified URLs that users will be redirected to after a successful authorization. After you pressed
|
||||
"Save", you will be presented with a client ID and a client secret. Please note them down and treat the client secret
|
||||
like a password; it should not become available to your users.
|
||||
|
||||
Obtaining an authorization grant
|
||||
--------------------------------
|
||||
|
||||
To authorize a new user, link or redirect them to the ``authorize`` endpoint, passing your client ID as a query
|
||||
parameter. Additionally, you can pass a scope (currently either ``read``, ``write``, or ``read write``)
|
||||
and an URL the user should be redirected to after successful or failed authorization. You also need to pass the
|
||||
``response_type`` parameter with a value of ``code``. Example::
|
||||
|
||||
https://pretix.eu/api/v1/oauth/authorize?client_id=lsLi0hNL0vk53mEdYjNJxHUn1PcO1R6wVg81dLNT&response_type=code&scope=read+write&redirect_uri=https://pretalx.com
|
||||
|
||||
To prevent CSRF attacks, you can also optionally pass a ``state`` parameter with a random string. Later, when
|
||||
redirecting back to your application, we will pass the same ``state`` parameter back to you, so you can compare if they
|
||||
match.
|
||||
|
||||
After the user granted or denied access, they will be redirected back either to the ``redirect_url`` you passed in the
|
||||
query or to the first redirect URL configured in your application settings.
|
||||
|
||||
On successful registration, we will append the query parameter ``code`` to the URL containing an authorization code.
|
||||
For example, we might redirect the user to this URL::
|
||||
|
||||
https://pretalx.com/?code=eYBBf8gmeD4E01HLoj0XflqO4Lg3Cw&state=e3KCh9mfx07qxU4bRpXk
|
||||
|
||||
You will need this ``code`` parameter to perform the next step.
|
||||
|
||||
On a failed registration, a query string like ``?error=access_denied`` will be appended to the redirection URL.
|
||||
|
||||
.. note:: In this step, the user is allowed to restrict your access to certain organizer accounts. If you try to
|
||||
re-authenticate the user later, the user might be instantly redirected back to you if authorization is already
|
||||
given and would therefore be unable to review their organizer restriction settings. You can append the
|
||||
``approval_prompt=force`` query parameter if you want to make sure the user actively needs to confirm the
|
||||
authorization.
|
||||
|
||||
Getting an access token
|
||||
-----------------------
|
||||
|
||||
Using the ``code`` value you obtained above and your client ID, you can now request an access token that actually gives
|
||||
access to the API. The ``token`` endpoint expects you to authenticate using `HTTP Basic authentication`_ using your client
|
||||
ID as a username and your client secret as a password. You are also required to again supply the same ``redirect_uri``
|
||||
parameter that you used for the authorization.
|
||||
|
||||
.. http:get:: /api/v1/oauth/token
|
||||
|
||||
Request a new access token
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/oauth/token HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Authorization: Basic bHNMaTBoTkwwdms1M21FZFlqTkp4SFVuMVBjTzFSNndWZzgxZExOVDplSmpzZVA0UjJMN0hMcjBiS0p1b3BmbnJtT2cyY3NDeTdYaFVVZ0FoalhUU0NhZHhRTjk3cVNvMkpPaXlWTFpQOEozaTVQd1FVdFIwNUNycG5ac2Z0bXJjdmNTbkZ1SkFmb2ZsUTdZUDRpSjZNTWFYTHIwQ0FpNlhIRFJjV1Awcg==
|
||||
|
||||
grant_type=authorization_code&code=eYBBf8gmeD4E01HLoj0XflqO4Lg3Cw&redirect_uri=https://pretalx.com
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"access_token": "i3ytqTSRWsKp16fqjekHXa4tdM4qNC",
|
||||
"expires_in": 86400,
|
||||
"token_type": "Bearer",
|
||||
"scope": "read write",
|
||||
"refresh_token": "XBK0r8z4A4TTeR9LyMUyU2AM5rqpXp"
|
||||
}
|
||||
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
|
||||
As you can see, you receive two types of tokens: One "access token", and one "refresh token". The access token is valid
|
||||
for a day and can be used to actually access the API. The refresh token does not have an expiration date and can be used
|
||||
to obtain a new access_token after a day, so you should make sure to store the access token safely if you need long-term
|
||||
access.
|
||||
|
||||
Using the API with an access token
|
||||
----------------------------------
|
||||
|
||||
You can supply a valid access token as a ``Bearer``-type token in the ``Authorization`` header to get API access.
|
||||
|
||||
.. sourcecode:: http
|
||||
:emphasize-lines: 3
|
||||
|
||||
GET /api/v1/organizers/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Authorization: Bearer i3ytqTSRWsKp16fqjekHXa4tdM4qNC
|
||||
|
||||
Refreshing an access token
|
||||
--------------------------
|
||||
|
||||
You can obtain a new access token using your refresh token any time. This can be done using the same ``token`` endpoint
|
||||
used to obtain the first access token above, but with a different set of parameters:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/oauth/token HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Authorization: Basic bHNMaTBoTkwwdms1M21FZFlqTkp4SFVuMVBjTzFSNndWZzgxZExOVDplSmpzZVA0UjJMN0hMcjBiS0p1b3BmbnJtT2cyY3NDeTdYaFVVZ0FoalhUU0NhZHhRTjk3cVNvMkpPaXlWTFpQOEozaTVQd1FVdFIwNUNycG5ac2Z0bXJjdmNTbkZ1SkFmb2ZsUTdZUDRpSjZNTWFYTHIwQ0FpNlhIRFJjV1Awcg==
|
||||
|
||||
grant_type=refresh_token&refresh_token=XBK0r8z4A4TTeR9LyMUyU2AM5rqpXp
|
||||
|
||||
The previous access token will instantly become invalid.
|
||||
|
||||
Revoking a token
|
||||
----------------
|
||||
|
||||
If you don't need a token any more or if you believe it may have been compromised, you can use the ``revoke_token``
|
||||
endpoint to revoke it.
|
||||
|
||||
.. http:get:: /api/v1/oauth/revoke_token
|
||||
|
||||
Revoke an access or refresh token. If you revoke an access token, you can still create a new one using the refresh token. If you
|
||||
revoke a refresh token, the connected access token will also be revoked.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/oauth/revoke_token HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Authorization: Basic bHNMaTBoTkwwdms1M21FZFlqTkp4SFVuMVBjTzFSNndWZzgxZExOVDplSmpzZVA0UjJMN0hMcjBiS0p1b3BmbnJtT2cyY3NDeTdYaFVVZ0FoalhUU0NhZHhRTjk3cVNvMkpPaXlWTFpQOEozaTVQd1FVdFIwNUNycG5ac2Z0bXJjdmNTbkZ1SkFmb2ZsUTdZUDRpSjZNTWFYTHIwQ0FpNlhIRFJjV1Awcg==
|
||||
|
||||
token=XBK0r8z4A4TTeR9LyMUyU2AM5rqpXp
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
If you want to revoke your client secret, you can generate a new one in the list of your managed applications in the
|
||||
pretix user interface.
|
||||
|
||||
Fetching the user profile
|
||||
-------------------------
|
||||
|
||||
If you need the user's meta data, you can fetch it here:
|
||||
|
||||
.. http:get:: /api/v1/me
|
||||
|
||||
Returns the profile of the authenticated user
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/me HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Authorization: Bearer i3ytqTSRWsKp16fqjekHXa4tdM4qNC
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
email: "admin@localhost",
|
||||
fullname: "John Doe",
|
||||
locale: "de",
|
||||
timezone: "Europe/Berlin"
|
||||
}
|
||||
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
.. _OAuth2: https://en.wikipedia.org/wiki/OAuth
|
||||
.. _OAuth2 Simplified: https://aaronparecki.com/oauth-2-simplified/
|
||||
.. _HTTP Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication
|
||||
31
doc/api/ratelimit.rst
Normal file
31
doc/api/ratelimit.rst
Normal file
@@ -0,0 +1,31 @@
|
||||
.. _`rest-ratelimit`:
|
||||
|
||||
Rate limiting
|
||||
=============
|
||||
|
||||
.. note:: This page only applies to the pretix Hosted service at pretix.eu. APIs of custom pretix installations do not
|
||||
enforce any rate limiting by default.
|
||||
|
||||
All authenticated requests to pretix' API are rate limited. If you exceed the limits, you will receive a response
|
||||
with HTTP status code ``429 Too Many Requests``. This response will have a ``Retry-After`` header, containing the number
|
||||
of seconds you are supposed to wait until you try again. We expect that all API clients respect this. If you continue
|
||||
to burst requests after a ``429`` status code, we might get in touch with you or, in extreme cases, disable your API
|
||||
access.
|
||||
|
||||
Currently, the following rate limits apply:
|
||||
|
||||
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== =================================================================================
|
||||
Authentication method Rate limit
|
||||
===================================== =================================================================================
|
||||
:ref:`rest-deviceauth` 360 requests per minute per device
|
||||
:ref:`rest-tokenauth` 360 requests per minute per organizer account
|
||||
:ref:`rest-oauth` 360 requests per minute per combination of accessed organizer and OAuth application
|
||||
Session authentication *Not an officially supported authentication method for external access*
|
||||
===================================== =================================================================================
|
||||
|
||||
If you require a higher rate limit, please get in touch at support@pretix.eu and tell us about your use case, we are
|
||||
sure we can work something out.
|
||||
264
doc/api/resources/carts.rst
Normal file
264
doc/api/resources/carts.rst
Normal file
@@ -0,0 +1,264 @@
|
||||
.. _rest-carts:
|
||||
|
||||
Cart positions
|
||||
==============
|
||||
|
||||
The API provides limited access to the cart position data model. This API currently only allows creating and deleting
|
||||
cart positions to reserve quota.
|
||||
|
||||
Cart position resource
|
||||
----------------------
|
||||
|
||||
The cart position resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the cart position
|
||||
cart_id string Identifier of the cart this belongs to. Needs to end
|
||||
in "@api" for API-created positions.
|
||||
datetime datetime Time of creation
|
||||
expires datetime The cart position will expire at this time and no longer block quota
|
||||
item integer ID of the item
|
||||
variation integer ID of the variation (or ``null``)
|
||||
price money (string) Price of this position
|
||||
attendee_name string Specified attendee name for this position (or ``null``)
|
||||
attendee_name_parts object of strings Composition of attendee name (i.e. first name, last name, …)
|
||||
attendee_email string Specified attendee email address for this position (or ``null``)
|
||||
voucher integer Internal ID of the voucher used for this position (or ``null``)
|
||||
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
|
||||
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
|
||||
answers list of objects Answers to user-defined questions
|
||||
├ question integer Internal ID of the answered question
|
||||
├ answer string Text representation of the answer
|
||||
├ question_identifier string The question's ``identifier`` field
|
||||
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
|
||||
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.17
|
||||
|
||||
This resource has been added.
|
||||
|
||||
|
||||
Cart position endpoints
|
||||
-----------------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/
|
||||
|
||||
Returns a list of API-created cart positions.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/cartpositions/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
X-Page-Generated: 2017-12-01T10:00:00Z
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"cart_id": "XwokV8FojQviD9jhtDzKvHFdlLRNMhlfo3cNjGbuK6MUTQDT@api",
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": null,
|
||||
"attendee_name_parts": {},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"datetime": "2018-06-11T10:00:00Z",
|
||||
"expires": "2018-06-11T10:00:00Z",
|
||||
"includes_tax": true,
|
||||
"answers": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
: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)/events/(event)/cartpositions/(id)/
|
||||
|
||||
Returns information on one cart position, identified by its internal ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/cartpositions/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"cart_id": "XwokV8FojQviD9jhtDzKvHFdlLRNMhlfo3cNjGbuK6MUTQDT@api",
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": null,
|
||||
"attendee_name_parts": {},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"datetime": "2018-06-11T10:00:00Z",
|
||||
"expires": "2018-06-11T10:00:00Z",
|
||||
"includes_tax": true,
|
||||
"answers": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the position 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 404: The requested cart position does not exist.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/
|
||||
|
||||
Creates a new cart position.
|
||||
|
||||
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
|
||||
|
||||
.. warning::
|
||||
|
||||
This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend.
|
||||
There is a lot that it does not or can not do, and you will need to be careful using it.
|
||||
It allows to bypass many of the restrictions imposed when creating a cart through the
|
||||
regular shop.
|
||||
|
||||
Specifically, this endpoint currently
|
||||
|
||||
* does not validate if products are only to be sold in a specific time frame
|
||||
|
||||
* does not validate if the event's ticket sales are already over or haven't started
|
||||
|
||||
* does not support add-on products at the moment
|
||||
|
||||
* does not check or calculate prices but believes any prices you send
|
||||
|
||||
* does not support the redemption of vouchers
|
||||
|
||||
* does not prevent you from buying items that can only be bought with a voucher
|
||||
|
||||
* does not support file upload questions
|
||||
|
||||
You can supply the following fields of the resource:
|
||||
|
||||
* ``cart_id`` (optional, needs to end in ``@api``)
|
||||
* ``item``
|
||||
* ``variation`` (optional)
|
||||
* ``price``
|
||||
* ``attendee_name`` **or** ``attendee_name_parts`` (optional)
|
||||
* ``attendee_email`` (optional)
|
||||
* ``subevent`` (optional)
|
||||
* ``expires`` (optional)
|
||||
* ``includes_tax`` (optional)
|
||||
* ``answers``
|
||||
|
||||
* ``question``
|
||||
* ``answer``
|
||||
* ``options``
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/cartpositions/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name_parts": {
|
||||
"given_name": "Peter",
|
||||
"family_name": "Miller"
|
||||
},
|
||||
"attendee_email": null,
|
||||
"answers": [
|
||||
{
|
||||
"question": 1,
|
||||
"answer": "23",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
(Full cart position resource, see above.)
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create a position for
|
||||
:param event: The ``slug`` field of the event to create a position for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The item 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.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/(id)/
|
||||
|
||||
Deletes a cart position, identified by its internal ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/cartpositions/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the position to delete
|
||||
: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 404: The requested cart position does not exist.
|
||||
@@ -1,3 +1,5 @@
|
||||
.. _`rest-categories`:
|
||||
|
||||
Item categories
|
||||
===============
|
||||
|
||||
@@ -14,6 +16,7 @@ Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the category
|
||||
name multi-lingual string The category's visible name
|
||||
internal_name string An optional name that is only used in the backend
|
||||
description multi-lingual string A public description (might include markdown, can
|
||||
be ``null``)
|
||||
position integer An integer, used for sorting the categories
|
||||
@@ -26,6 +29,10 @@ is_addon boolean If ``True``, it
|
||||
|
||||
The operations POST, PATCH, PUT and DELETE have been added.
|
||||
|
||||
.. versionchanged:: 1.16
|
||||
|
||||
The field ``internal_name`` has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -58,6 +65,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Tickets"},
|
||||
"internal_name": "",
|
||||
"description": {"en": "Tickets are what you need to get in."},
|
||||
"position": 1,
|
||||
"is_addon": false
|
||||
@@ -99,6 +107,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Tickets"},
|
||||
"internal_name": "",
|
||||
"description": {"en": "Tickets are what you need to get in."},
|
||||
"position": 1,
|
||||
"is_addon": false
|
||||
@@ -126,6 +135,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"name": {"en": "Tickets"},
|
||||
"internal_name": "",
|
||||
"description": {"en": "Tickets are what you need to get in."},
|
||||
"position": 1,
|
||||
"is_addon": false
|
||||
@@ -142,6 +152,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Tickets"},
|
||||
"internal_name": "",
|
||||
"description": {"en": "Tickets are what you need to get in."},
|
||||
"position": 1,
|
||||
"is_addon": false
|
||||
@@ -187,6 +198,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Tickets"},
|
||||
"internal_name": "",
|
||||
"description": {"en": "Tickets are what you need to get in."},
|
||||
"position": 1,
|
||||
"is_addon": true
|
||||
|
||||
@@ -44,6 +44,10 @@ include_pending boolean If ``true``, th
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
The ``../status/`` detail endpoint has been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/
|
||||
|
||||
Returns a list of all check-in lists within a given event.
|
||||
@@ -128,6 +132,72 @@ Endpoints
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(id)/status/
|
||||
|
||||
Returns detailed status information on a check-in list, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/status/ 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
|
||||
|
||||
{
|
||||
"checkin_count": 17,
|
||||
"position_count": 42,
|
||||
"event": {
|
||||
"name": "Demo Converence",
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"name": "T-Shirt",
|
||||
"id": 1,
|
||||
"checkin_count": 1,
|
||||
"admission": False,
|
||||
"position_count": 1,
|
||||
"variations": [
|
||||
{
|
||||
"value": "Red",
|
||||
"id": 1,
|
||||
"checkin_count": 1,
|
||||
"position_count": 12
|
||||
},
|
||||
{
|
||||
"value": "Blue",
|
||||
"id": 2,
|
||||
"checkin_count": 4,
|
||||
"position_count": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ticket",
|
||||
"id": 2,
|
||||
"checkin_count": 15,
|
||||
"admission": True,
|
||||
"position_count": 22,
|
||||
"variations": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the check-in list 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/(organizer)/events/(event)/checkinlists/
|
||||
|
||||
Creates a new check-in list.
|
||||
@@ -254,6 +324,18 @@ Endpoints
|
||||
Order position endpoints
|
||||
------------------------
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
The order positions endpoint has been extended by the filter queries ``item__in``, ``variation__in``,
|
||||
``order__status__in``, ``subevent__in``, ``addon_to__in``, and ``search``. The search for attendee names and order
|
||||
codes is now case-insensitive.
|
||||
|
||||
The ``.../redeem/`` endpoint has been added.
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
The order positions endpoint has been extended by the filter queries ``voucher`` and ``voucher__code``.
|
||||
|
||||
.. 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
|
||||
@@ -289,6 +371,9 @@ Order position endpoints
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
@@ -297,6 +382,7 @@ Order position endpoints
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"checkins": [
|
||||
{
|
||||
"list": 1,
|
||||
@@ -325,15 +411,26 @@ Order position endpoints
|
||||
``order__datetime``, ``positionid``, ``attendee_name``, ``last_checked_in`` and ``order__email``. Default:
|
||||
``attendee_name,positionid``
|
||||
:query string order: Only return positions of the order with the given order code
|
||||
:query string search: Fuzzy search matching the attendee name, order code, invoice address name as well as to the beginning of the secret.
|
||||
:query integer item: Only return positions with the purchased item matching the given ID.
|
||||
:query integer item__in: Only return positions with the purchased item matching one of the given comma-separated IDs.
|
||||
:query integer variation: Only return positions with the purchased item variation matching the given ID.
|
||||
:query integer variation__in: Only return positions with one of the purchased item variation matching the given
|
||||
comma-separated IDs.
|
||||
:query string attendee_name: Only return positions with the given value in the attendee_name field. Also, add-on
|
||||
products positions are shown if they refer to an attendee with the given name.
|
||||
:query string secret: Only return positions with the given ticket secret.
|
||||
:query bollean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been
|
||||
checked in already on this list.
|
||||
:query string order__status: Only return positions with the given order status.
|
||||
:query string order__status__in: Only return positions with one the given comma-separated order status.
|
||||
:query boolean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been
|
||||
checked in already.
|
||||
:query integer subevent: Only return positions of the sub-event with the given ID
|
||||
:query integer subevent__in: Only return positions of one of the sub-events with the given comma-separated IDs
|
||||
:query integer addon_to: Only return positions that are add-ons to the position with the given ID.
|
||||
:query integer addon_to__in: Only return positions that are add-ons to one of the positions with the given
|
||||
comma-separated IDs.
|
||||
:query string voucher: Only return positions with a specific voucher.
|
||||
:query string voucher__code: Only return positions with a specific voucher code.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param list: The ID of the check-in list to look for
|
||||
@@ -342,17 +439,19 @@ Order position endpoints
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested check-in list does not exist.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)
|
||||
.. 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.
|
||||
|
||||
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/ HTTP/1.1
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/23442/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
@@ -372,6 +471,9 @@ Order position endpoints
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
@@ -380,6 +482,7 @@ Order position endpoints
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"checkins": [
|
||||
{
|
||||
"list": 1,
|
||||
@@ -409,3 +512,138 @@ Order position endpoints
|
||||
: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 position or check-in list does not exist.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/redeem/
|
||||
|
||||
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
|
||||
accepts a number of optional requests in the body.
|
||||
|
||||
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
|
||||
|
||||
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||
you do not implement question handling in your user interface, you **must**
|
||||
set this to ``false``. In that case, questions will just be ignored. Defaults
|
||||
to ``true``.
|
||||
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
|
||||
:<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``.
|
||||
:<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
|
||||
allows for a certain level of idempotency and enables you to re-try after a connection failure.
|
||||
:<json object answers: If questions are supported/required, you may/must supply a mapping of question IDs to their
|
||||
respective answers. The answers should always be strings. In case of (multiple-)choice-type
|
||||
answers, the string should contain the (comma-separated) IDs of the selected options.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/234/redeem/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"force": false,
|
||||
"ignore_unpaid": false,
|
||||
"nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
|
||||
"datetime": null,
|
||||
"questions_supported": true,
|
||||
"answers": {
|
||||
"4": "XS"
|
||||
}
|
||||
}
|
||||
|
||||
**Example successful response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "ok",
|
||||
"position": {
|
||||
…
|
||||
}
|
||||
}
|
||||
|
||||
**Example response with required questions**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 400 Bad Request
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "incomplete",
|
||||
"position": {
|
||||
…
|
||||
},
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"question": {"en": "T-Shirt size"},
|
||||
"type": "C",
|
||||
"required": false,
|
||||
"items": [1, 2],
|
||||
"position": 1,
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": true,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 0,
|
||||
"answer": {"en": "S"}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"identifier": "DFEMJWMJ",
|
||||
"position": 1,
|
||||
"answer": {"en": "M"}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"identifier": "W9AH7RDE",
|
||||
"position": 2,
|
||||
"answer": {"en": "L"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Example error response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "unpaid",
|
||||
"position": {
|
||||
…
|
||||
}
|
||||
}
|
||||
|
||||
Possible error reasons:
|
||||
|
||||
* ``unpaid`` - Ticket is not paid for or has been refunded
|
||||
* ``already_redeemed`` - Ticket already has been redeemed
|
||||
* ``product`` - Tickets with this product may not be scanned at this device
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param list: The ID of the check-in list to look for
|
||||
:param id: The ``id`` field of the order position to fetch
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: Invalid or incomplete request, see above
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order position or check-in list does not exist.
|
||||
|
||||
@@ -15,6 +15,7 @@ name multi-lingual string The event's ful
|
||||
slug string A short form of the name, used e.g. in URLs.
|
||||
live boolean If ``true``, the event ticket shop is publicly
|
||||
available.
|
||||
testmode boolean If ``true``, the ticket shop is in test mode.
|
||||
currency string The currency this event is handled in.
|
||||
date_from datetime The event's start date
|
||||
date_to datetime The event's end date (or ``null``)
|
||||
@@ -25,14 +26,30 @@ presale_start datetime The date at whi
|
||||
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
|
||||
event
|
||||
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
|
||||
event.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
|
||||
The ``meta_data`` field has been added.
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
The ``plugins`` field has been added.
|
||||
The operations POST, PATCH, PUT and DELETE have been added.
|
||||
|
||||
.. versionchanged:: 2.1
|
||||
|
||||
Filters have been added to the list of events.
|
||||
|
||||
.. versionchanged:: 2.5
|
||||
|
||||
The ``testmode`` attribute has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -40,6 +57,8 @@ Endpoints
|
||||
|
||||
Returns a list of all events within a given organizer the authenticated user/token has access to.
|
||||
|
||||
Permission required: "Can change event settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -65,6 +84,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -74,12 +94,24 @@ Endpoints
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"has_subevents": false,
|
||||
"meta_data": {}
|
||||
"meta_data": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer"
|
||||
"pretix.plugins.stripe"
|
||||
"pretix.plugins.paypal"
|
||||
"pretix.plugins.ticketoutputpdf"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:query is_public: If set to ``true``/``false``, only events with a matching value of ``is_public`` are returned.
|
||||
:query live: If set to ``true``/``false``, only events with a matching value of ``live`` are returned.
|
||||
:query has_subevents: If set to ``true``/``false``, only events with a matching value of ``has_subevents`` are returned.
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned.
|
||||
:query 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.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
@@ -89,6 +121,8 @@ Endpoints
|
||||
|
||||
Returns information on one event, identified by its slug.
|
||||
|
||||
Permission required: "Can change event settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -109,6 +143,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -118,7 +153,13 @@ Endpoints
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"has_subevents": false,
|
||||
"meta_data": {}
|
||||
"meta_data": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer"
|
||||
"pretix.plugins.stripe"
|
||||
"pretix.plugins.paypal"
|
||||
"pretix.plugins.ticketoutputpdf"
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -126,3 +167,247 @@ Endpoints
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/
|
||||
|
||||
Creates a new event
|
||||
|
||||
Please note that events cannot be created as 'live' using this endpoint. Quotas and payment must be added to the
|
||||
event before sales can go live.
|
||||
|
||||
Permission required: "Can create events"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
"is_public": false,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
"is_public": false,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create.
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The 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: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.
|
||||
|
||||
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.
|
||||
|
||||
Please note that you can only copy from events under the same organizer.
|
||||
|
||||
Permission required: "Can create events"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/clone/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
"is_public": false,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
"is_public": false,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create.
|
||||
:param event: The ``slug`` field of the event to copy settings and items from.
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The event could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/
|
||||
|
||||
Updates an event
|
||||
|
||||
Permission required: "Can change event settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer",
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal",
|
||||
"pretix.plugins.pretixdroid"
|
||||
]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
"is_public": false,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer",
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal",
|
||||
"pretix.plugins.pretixdroid"
|
||||
]
|
||||
}
|
||||
|
||||
: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 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.
|
||||
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/
|
||||
|
||||
Delete an 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/ 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 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.
|
||||
|
||||
@@ -20,3 +20,5 @@ Resources and endpoints
|
||||
vouchers
|
||||
checkinlists
|
||||
waitinglist
|
||||
carts
|
||||
webhooks
|
||||
|
||||
@@ -223,3 +223,59 @@ Endpoints
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||
seconds.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/
|
||||
|
||||
Cancels the invoice and creates a new one.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/reissue/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
Content-Type: application/pdf
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param invoice_no: The ``invoice_no`` field of the invoice to reissue
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The invoice has already been canceled
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/regenerate/
|
||||
|
||||
Re-generates the invoice from order data.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/regenerate/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
Content-Type: application/pdf
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param invoice_no: The ``invoice_no`` field of the invoice to regenerate
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The invoice has already been canceled
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.. _rest-items:
|
||||
|
||||
Items
|
||||
=====
|
||||
|
||||
@@ -14,6 +16,7 @@ Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the item
|
||||
name multi-lingual string The item's visible name
|
||||
internal_name string An optional name that is only used in the backend
|
||||
default_price money (string) The item price that is applied if the price is not
|
||||
overwritten by variations or other options.
|
||||
category integer The ID of the category this item belongs to
|
||||
@@ -34,6 +37,8 @@ admission boolean ``True`` for it
|
||||
position integer An integer, used for sorting
|
||||
picture string A product picture to be displayed in the shop
|
||||
(read-only).
|
||||
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
|
||||
@@ -54,11 +59,23 @@ max_per_order integer This product ca
|
||||
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
|
||||
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``,
|
||||
tickets are generated even if this is a
|
||||
non-admission or add-on product, regardless of event
|
||||
settings. If this is ``null``, regular ticketing
|
||||
rules apply.
|
||||
has_variations boolean Shows whether or not this item has variations.
|
||||
variations list of objects A list with one object for each variation of this item.
|
||||
Can be empty. Only writable during creation,
|
||||
use separate endpoint to modify this later.
|
||||
├ id integer Internal ID of the variation
|
||||
├ value multi-lingual string The "name" of the variation
|
||||
├ default_price money (string) The price set directly for this variation or ``null``
|
||||
├ price money (string) The price used for this variation. This is either the
|
||||
same as ``default_price`` if that value is set or equal
|
||||
@@ -88,6 +105,22 @@ addons list of objects Definition of a
|
||||
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
|
||||
The attribute ``price_included`` has been added to ``addons``.
|
||||
|
||||
.. versionchanged:: 1.16
|
||||
|
||||
The ``internal_name`` and ``original_price`` fields have been added.
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
The field ``require_approval`` has been added.
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
The ``sales_channels`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.4
|
||||
|
||||
The ``generate_tickets`` attribute has been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Please note that an item either always has variations or never has. Once created with variations the item can never
|
||||
@@ -129,7 +162,10 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Standard ticket"},
|
||||
"internal_name": "",
|
||||
"sales_channels": ["web"],
|
||||
"default_price": "23.00",
|
||||
"original_price": null,
|
||||
"category": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
@@ -148,6 +184,8 @@ Endpoints
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": false,
|
||||
"generate_tickets": null,
|
||||
"require_approval": false,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
@@ -211,7 +249,10 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Standard ticket"},
|
||||
"internal_name": "",
|
||||
"sales_channels": ["web"],
|
||||
"default_price": "23.00",
|
||||
"original_price": null,
|
||||
"category": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
@@ -226,10 +267,12 @@ Endpoints
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"generate_tickets": null,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": false,
|
||||
"require_approval": false,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
@@ -274,7 +317,10 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Standard ticket"},
|
||||
"internal_name": "",
|
||||
"sales_channels": ["web"],
|
||||
"default_price": "23.00",
|
||||
"original_price": null,
|
||||
"category": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
@@ -289,9 +335,11 @@ Endpoints
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"generate_tickets": null,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"require_approval": false,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
@@ -324,7 +372,10 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Standard ticket"},
|
||||
"internal_name": "",
|
||||
"sales_channels": ["web"],
|
||||
"default_price": "23.00",
|
||||
"original_price": null,
|
||||
"category": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
@@ -341,8 +392,10 @@ Endpoints
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"generate_tickets": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": true,
|
||||
"require_approval": false,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
@@ -406,7 +459,10 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Ticket"},
|
||||
"internal_name": "",
|
||||
"sales_channels": ["web"],
|
||||
"default_price": "25.00",
|
||||
"original_price": null,
|
||||
"category": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
@@ -420,11 +476,13 @@ Endpoints
|
||||
"available_until": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"generate_tickets": null,
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": true,
|
||||
"require_approval": false,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -128,7 +128,7 @@ Endpoints
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/questions/1/options/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"identifier": "LVETRWVU",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.. spelling:: checkin
|
||||
|
||||
.. _rest-questions:
|
||||
|
||||
Questions
|
||||
=========
|
||||
|
||||
@@ -59,6 +61,11 @@ options list of objects In case of ques
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
The questions endpoint has been extended by the filter queries ``ask_during_checkin``, ``requred``, and
|
||||
``identifier``.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/questions/
|
||||
|
||||
Returns a list of all questions within a given event.
|
||||
@@ -120,6 +127,9 @@ Endpoints
|
||||
: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 ``id`` and ``position``.
|
||||
Default: ``position``
|
||||
:query string identifier: Only return questions with the given identifier string
|
||||
:query boolean ask_during_checkin: Only return questions that are or are not to be asked during check-in
|
||||
:query boolean required: Only return questions that are or are not required to fill in
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.. _rest-quotas:
|
||||
|
||||
Quotas
|
||||
======
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.. _rest-subevents:
|
||||
|
||||
Event series dates / Sub-events
|
||||
===============================
|
||||
|
||||
@@ -15,6 +17,7 @@ Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the sub-event
|
||||
name multi-lingual string The sub-event's full name
|
||||
event string The slug of the parent event
|
||||
active boolean If ``true``, the sub-event ticket shop is publicly
|
||||
available.
|
||||
date_from datetime The sub-event's start date
|
||||
@@ -38,6 +41,10 @@ meta_data dict Values set for
|
||||
|
||||
The ``meta_data`` field has been added.
|
||||
|
||||
.. versionchanged:: 2.1
|
||||
|
||||
The ``event`` field has been added, together with filters on the list of dates and an organizer-level list.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -70,6 +77,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"event": "sampleconf",
|
||||
"active": false,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -90,6 +98,10 @@ Endpoints
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned.
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
|
||||
:query 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
|
||||
:statuscode 200: no error
|
||||
@@ -119,6 +131,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"event": "sampleconf",
|
||||
"active": false,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -142,3 +155,63 @@ Endpoints
|
||||
: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: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.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/subevents/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"event": "sampleconf",
|
||||
"active": false,
|
||||
"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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned.
|
||||
:query event__live: If set to ``true``/``false``, only events with a matching value of ``live`` on the parent event are returned.
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
|
||||
:query 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
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.. _rest-taxrules:
|
||||
|
||||
Tax rules
|
||||
=========
|
||||
|
||||
|
||||
@@ -231,6 +231,76 @@ Endpoints
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
:statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/vouchers/batch_create/
|
||||
|
||||
Creates multiple new vouchers atomically.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/vouchers/batch_create/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 408
|
||||
|
||||
[
|
||||
{
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
"max_usages": 1,
|
||||
"valid_until": null,
|
||||
"block_quota": false,
|
||||
"allow_ignore_quota": false,
|
||||
"price_mode": "set",
|
||||
"value": "12.00",
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": "",
|
||||
"subevent": null
|
||||
},
|
||||
{
|
||||
"code": "ASDKLJCYXCASDASD",
|
||||
"max_usages": 1,
|
||||
"valid_until": null,
|
||||
"block_quota": false,
|
||||
"allow_ignore_quota": false,
|
||||
"price_mode": "set",
|
||||
"value": "12.00",
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": "",
|
||||
"subevent": null
|
||||
},
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
…
|
||||
}, …
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a vouchers for
|
||||
:param event: The ``slug`` field of the event to create a vouchers for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The vouchers 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.
|
||||
:statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/vouchers/(id)/
|
||||
|
||||
Update a voucher. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
@@ -251,7 +321,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"price_mode": "set",
|
||||
"value": "24.00",
|
||||
"value": "24.00"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
@@ -27,6 +27,12 @@ subevent integer ID of the date
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added as well as a method to send out
|
||||
vouchers.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -121,3 +127,161 @@ Endpoints
|
||||
: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/(organizer)/events/(event)/waitinglistentries/
|
||||
|
||||
Create a new entry.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/waitinglistentries/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 408
|
||||
|
||||
{
|
||||
"email": "waiting@example.org",
|
||||
"item": 3,
|
||||
"variation": null,
|
||||
"locale": "de",
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"email": "waiting@example.org",
|
||||
"voucher": null,
|
||||
"item": 3,
|
||||
"variation": null,
|
||||
"locale": "de",
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create an entry for
|
||||
:param event: The ``slug`` field of the event to create an entry for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The voucher 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 **or** entries cannot be created for this item at this time.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/waitinglistentries/(id)/
|
||||
|
||||
Update an entry. 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``, ``voucher`` and ``created`` fields. You can only change
|
||||
an entry as long as no ``voucher`` is set.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/waitinglistentries/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 408
|
||||
|
||||
{
|
||||
"item": 4
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"email": "waiting@example.org",
|
||||
"voucher": null,
|
||||
"item": 4,
|
||||
"variation": null,
|
||||
"locale": "de",
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the entry to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The entry 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 create this
|
||||
resource **or** entries cannot be created for this item at this time **or** this entry already
|
||||
has a voucher assigned
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/waitinglistentries/(id)/send_voucher/
|
||||
|
||||
Manually sends a voucher to someone on the waiting list
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/waitinglistentries/1/send_voucher/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 0
|
||||
|
||||
**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 entry to modify
|
||||
:statuscode 204: no error
|
||||
:statuscode 400: The voucher could not be sent out, see body for details (e.g. voucher has already been sent or
|
||||
item is not available).
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to do this
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/waitinglistentries/(id)/
|
||||
|
||||
Delete an entry. Note that you cannot delete an entry once it is assigned a voucher.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/waitinglistentries/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 entry 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 **or** this entry already has a voucher assigned.
|
||||
|
||||
242
doc/api/resources/webhooks.rst
Normal file
242
doc/api/resources/webhooks.rst
Normal file
@@ -0,0 +1,242 @@
|
||||
.. _`rest-webhooks`:
|
||||
|
||||
Webhooks
|
||||
========
|
||||
|
||||
.. note:: This page is about how to modify webhook settings themselves through the REST API. If you just want to know
|
||||
how webhooks work, go here: :ref:`webhooks`
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The webhook resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the webhook
|
||||
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
|
||||
on all events of this organizer
|
||||
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
|
||||
valid values
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
The following values for ``action_types`` are valid with pretix core:
|
||||
|
||||
* ``pretix.event.order.placed``
|
||||
* ``pretix.event.order.paid``
|
||||
* ``pretix.event.order.canceled``
|
||||
* ``pretix.event.order.expired``
|
||||
* ``pretix.event.order.modified``
|
||||
* ``pretix.event.order.contact.changed``
|
||||
* ``pretix.event.order.changed.*``
|
||||
* ``pretix.event.order.refund.created.externally``
|
||||
* ``pretix.event.order.approved``
|
||||
* ``pretix.event.order.denied``
|
||||
* ``pretix.event.checkin``
|
||||
* ``pretix.event.checkin.reverted``
|
||||
|
||||
Installed plugins might register more valid values.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/webhooks/
|
||||
|
||||
Returns a list of all webhooks within a given organizer.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/webhooks/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 2,
|
||||
"enabled": true,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
: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
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/webhooks/(id)/
|
||||
|
||||
Returns information on one webhook, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/webhooks/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": 2,
|
||||
"enabled": true,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param id: The ``id`` field of the webhook to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/webhooks/
|
||||
|
||||
Creates a new webhook
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/webhooks/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"enabled": true,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"enabled": true,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a webhook for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The webhook 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:patch:: /api/v1/organizers/(organizer)/webhooks/(id)/
|
||||
|
||||
Update a webhook. 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/webhooks/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"enabled": false,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the webhook to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The webhook could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/webhook/(id)/
|
||||
|
||||
Delete a webhook. Currently, this will not delete but just disable the webhook.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/webhooks/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 id: The ``id`` field of the webhook to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource.
|
||||
36
doc/api/tokenauth.rst
Normal file
36
doc/api/tokenauth.rst
Normal file
@@ -0,0 +1,36 @@
|
||||
.. _`rest-tokenauth`:
|
||||
|
||||
Token-based authentication
|
||||
==========================
|
||||
|
||||
Obtaining an API token
|
||||
----------------------
|
||||
|
||||
To authenticate your API requests with Tokens, you need to obtain a team-level API token.
|
||||
You can create a token in the pretix web interface on the level of organizer teams. Create
|
||||
a new team or choose an existing team that has the level of permissions the token should
|
||||
have and create a new token using the form below the list of team members:
|
||||
|
||||
.. image:: img/token_form.png
|
||||
:class: screenshot
|
||||
|
||||
You can enter a description for the token to distinguish from other tokens later on.
|
||||
Once you click "Add", you will be provided with an API token in the success message.
|
||||
Copy this token, as you won't be able to retrieve it again.
|
||||
|
||||
.. image:: img/token_success.png
|
||||
:class: screenshot
|
||||
|
||||
Using an API token
|
||||
------------------
|
||||
|
||||
You need to include the API token with every request to pretix' API in the ``Authorization`` header
|
||||
like the following:
|
||||
|
||||
.. sourcecode:: http
|
||||
:emphasize-lines: 3
|
||||
|
||||
GET /api/v1/organizers/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k
|
||||
|
||||
108
doc/api/webhooks.rst
Normal file
108
doc/api/webhooks.rst
Normal file
@@ -0,0 +1,108 @@
|
||||
.. _`webhooks`:
|
||||
|
||||
Webhooks
|
||||
========
|
||||
|
||||
pretix can send webhook calls to notify your application of any changes that happen inside pretix. This is especially
|
||||
useful for everything triggered by an actual user, such as a new ticket sale or the arrival of a payment.
|
||||
|
||||
You can register any number of webhook URLs that pretix will notify any time one of the supported events occurs inside
|
||||
your organizer account. A great example use case of webhooks would be to add the buyer to your mailing list every time
|
||||
a new order comes in.
|
||||
|
||||
Configuring webhooks
|
||||
--------------------
|
||||
|
||||
You can find the list of your active webhooks in the "Webhook" section of your organizer account:
|
||||
|
||||
.. thumbnail:: ../screens/organizer/webhook_list.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Click "Create webhook" if you want to add a new URL. You will then be able to enter the URL pretix shall call for
|
||||
notifications. You need to select any number of notification types that you want to receive and you can optionally
|
||||
filter the events you want to receive notifications for.
|
||||
|
||||
.. thumbnail:: ../screens/organizer/webhook_edit.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
You can also configure webhooks :ref:`through the API itself <rest-webhooks>`.
|
||||
|
||||
Receiving webhooks
|
||||
------------------
|
||||
|
||||
Creating a webhook endpoint on your server is no different from creating any other page on your website. If your
|
||||
website is written in PHP, you might just create a new ``.php`` file on your server; if you use a web framework like
|
||||
Symfony or Django, you would just create a new route with the desired URL.
|
||||
|
||||
We will call your URL with a HTTP ``POST`` request with a ``JSON`` body. In PHP, you can parse this like this::
|
||||
|
||||
$input = @file_get_contents('php://input');
|
||||
$event_json = json_decode($input);
|
||||
// Do something with $event_json
|
||||
|
||||
In Django, you would create a view like this::
|
||||
|
||||
def my_webhook_view(request):
|
||||
event_json = json.loads(request.body)
|
||||
# Do something with event_json
|
||||
return HttpResponse(status=200)
|
||||
|
||||
More samples for the language of your choice are easy to find online.
|
||||
|
||||
The exact body of the request varies by notification type, but for the main types included with pretix core, such as
|
||||
those related to changes of an order, it will look like this::
|
||||
|
||||
{
|
||||
"notification_id": 123455,
|
||||
"organizer": "acmecorp",
|
||||
"event": "democon",
|
||||
"code": "ABC23",
|
||||
"action": "pretix.event.order.placed"
|
||||
}
|
||||
|
||||
Notifications regarding a check-in will contain more details like ``orderposition_id``
|
||||
and ``checkin_list``.
|
||||
|
||||
.. warning:: You should not trust data supplied to your webhook, but only use it as a trigger to fetch updated data.
|
||||
Anyone could send data there if they guess the correct URL and you won't be able to tell. Therefore, we
|
||||
only include the minimum amount of data necessary for you to fetch the changed objects from our
|
||||
:ref:`rest-api` in an authenticated way.
|
||||
|
||||
If you want to further prevent others from accessing your webhook URL, you can also use `Basic authentication`_ and
|
||||
supply the URL to us in the format of ``https://username:password@domain.com/path/``.
|
||||
We recommend that you use HTTPS for your webhook URL and might require it in the future. If HTTPS is used, we require
|
||||
that a valid certificate is in use.
|
||||
|
||||
.. note:: If you use a web framework that makes use of automatic CSRF protection, this protection might prevent us
|
||||
from calling your webhook URL. In this case, we recommend that you turn of CSRF protection selectively
|
||||
for that route. In Django, you can do this by putting the ``@csrf_exempt`` decorator on your view. In
|
||||
Rails, you can pass an ``except`` parameter to ``protect_from_forgery``.
|
||||
|
||||
|
||||
Responding to a webhook
|
||||
-----------------------
|
||||
|
||||
If you successfully received a webhook call, your endpoint should return a HTTP status code between ``200`` and ``299``.
|
||||
If any other status code is returned, we will assume you did not receive the call. This does mean that any redirection
|
||||
or ``304 Not Modified`` response will be treated as a failure. pretix will not follow any ``301`` or ``302`` redirect
|
||||
headers and pretix will ignore all other information in your response headers or body.
|
||||
|
||||
If we do not receive a status code in the range of ``200`` and ``299``, pretix will retry to deliver for up to three
|
||||
days with an exponential back off. Therefore, we recommend that you implement your endpoint in a way where calling it
|
||||
multiple times for the same event due to a perceived error does not do any harm.
|
||||
|
||||
There is only one exception: If status code ``410 Gone`` is returned, we will assume the
|
||||
endpoint does not exist any more and automatically disable the webhook.
|
||||
|
||||
.. note:: If you use a self-hosted version of pretix (i.e. not our SaaS offering at pretix.eu) and you did not
|
||||
configure a background task queue, failed webhooks will not be retried.
|
||||
|
||||
Debugging webhooks
|
||||
------------------
|
||||
|
||||
If you want to debug your webhooks, you can view a log of all sent notifications and the responses of your server for
|
||||
30 days right next to your configuration.
|
||||
|
||||
.. _Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication
|
||||
@@ -64,7 +64,7 @@ Similarly, there is ``organizer_permission_required`` and ``OrganizerPermissionR
|
||||
event-related views, there is also a signal that allows you to add the view to the event navigation like this::
|
||||
|
||||
|
||||
from django.core.urlresolvers import resolve, reverse
|
||||
from django.urls import resolve, reverse
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from pretix.control.signals import nav_event
|
||||
|
||||
109
doc/development/api/email.rst
Normal file
109
doc/development/api/email.rst
Normal file
@@ -0,0 +1,109 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
Writing an HTML e-mail renderer plugin
|
||||
======================================
|
||||
|
||||
An email renderer class controls how the HTML part of e-mails sent by pretix is built.
|
||||
The creation of such a plugin is very similar to creating an export output.
|
||||
|
||||
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
|
||||
|
||||
Output registration
|
||||
-------------------
|
||||
|
||||
The email HTML renderer API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available email renderers. Your plugin
|
||||
should listen for this signal and return the subclass of ``pretix.base.email.BaseHTMLMailRenderer``
|
||||
that we'll provide in this plugin::
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.signals import register_html_mail_renderers
|
||||
|
||||
|
||||
@receiver(register_html_mail_renderers, dispatch_uid="renderer_custom")
|
||||
def register_mail_renderers(sender, **kwargs):
|
||||
from .email import MyMailRenderer
|
||||
return MyMailRenderer
|
||||
|
||||
|
||||
The renderer class
|
||||
------------------
|
||||
|
||||
.. class:: pretix.base.email.BaseHTMLMailRenderer
|
||||
|
||||
The central object of each email renderer is the subclass of ``BaseHTMLMailRenderer``.
|
||||
|
||||
.. py:attribute:: BaseHTMLMailRenderer.event
|
||||
|
||||
The default constructor sets this property to the event we are currently
|
||||
working for.
|
||||
|
||||
.. autoattribute:: identifier
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: verbose_name
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: thumbnail_filename
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: is_available
|
||||
|
||||
.. automethod:: render
|
||||
|
||||
This is an abstract method, you **must** implement this!
|
||||
|
||||
Helper class for template-base renderers
|
||||
----------------------------------------
|
||||
|
||||
The email renderer that ships with pretix is based on Django templates to generate HTML.
|
||||
In case you also want to render emails based on a template, we provided a ready-made base
|
||||
class ``TemplateBasedMailRenderer`` that you can re-use to perform the following steps:
|
||||
|
||||
* Convert the body text and the signature to HTML using our markdown renderer
|
||||
|
||||
* Render the template
|
||||
|
||||
* Call `inlinestyler`_ to convert all ``<style>`` style sheets to inline ``style=""``
|
||||
attributes for better compatibility
|
||||
|
||||
To use it, you just need to implement some variables::
|
||||
|
||||
class ClassicMailRenderer(TemplateBasedMailRenderer):
|
||||
verbose_name = _('pretix default')
|
||||
identifier = 'classic'
|
||||
thumbnail_filename = 'pretixbase/email/thumb.png'
|
||||
template_name = 'pretixbase/email/plainwrapper.html'
|
||||
|
||||
The template is passed the following context variables:
|
||||
|
||||
``site``
|
||||
Name of the pretix installation (``settings.PRETIX_INSTANCE_NAME``)
|
||||
|
||||
``site_url``
|
||||
Root URL of the pretix installation (``settings.SITE_URL``)
|
||||
|
||||
``body``
|
||||
The body as markdown (render with ``{{ body|safe }}``)
|
||||
|
||||
``subject``
|
||||
The email subject
|
||||
|
||||
``color``
|
||||
The primary color of the event
|
||||
|
||||
``event``
|
||||
The ``Event`` object
|
||||
|
||||
``signature`` (optional, only if configured)
|
||||
The body as markdown (render with ``{{ signature|safe }}``)
|
||||
|
||||
``order`` (optional, only if applicable)
|
||||
The ``Order`` object
|
||||
|
||||
.. _inlinestyler: https://pypi.org/project/inlinestyler/
|
||||
@@ -11,7 +11,8 @@ Core
|
||||
----
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
|
||||
item_copy_data, register_sales_channels
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
@@ -25,7 +26,7 @@ Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content
|
||||
: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
|
||||
@@ -47,7 +48,8 @@ Backend
|
||||
-------
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: nav_event, html_head, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings, order_info, event_settings_widget
|
||||
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
|
||||
order_info, event_settings_widget, oauth_application_registered, order_position_buttons
|
||||
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
@@ -56,6 +58,12 @@ Backend
|
||||
Vouchers
|
||||
""""""""
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: item_forms
|
||||
|
||||
Vouchers
|
||||
""""""""
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: voucher_form_class, voucher_form_html, voucher_form_validation
|
||||
|
||||
@@ -68,5 +76,5 @@ Dashboards
|
||||
Ticket designs
|
||||
""""""""""""""
|
||||
|
||||
.. automodule:: pretix.plugins.ticketoutputpdf.signals
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: layout_text_variables
|
||||
|
||||
@@ -10,6 +10,10 @@ Contents:
|
||||
exporter
|
||||
ticketoutput
|
||||
payment
|
||||
payment_2.0
|
||||
email
|
||||
invoice
|
||||
shredder
|
||||
customview
|
||||
general
|
||||
quality
|
||||
|
||||
@@ -23,7 +23,7 @@ that we'll provide in this plugin::
|
||||
|
||||
|
||||
@receiver(register_invoice_renderers, dispatch_uid="output_custom")
|
||||
def register_infoice_renderers(sender, **kwargs):
|
||||
def register_invoice_renderers(sender, **kwargs):
|
||||
from .invoice import MyInvoiceRenderer
|
||||
return MyInvoiceRenderer
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ is very similar to creating an export output.
|
||||
|
||||
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
|
||||
|
||||
.. warning:: We changed our payment provider API a lot in pretix 2.x. Our documentation page on :ref:`payment2.0`
|
||||
might be insightful even if you do not have a payment provider to port, as it outlines the rationale
|
||||
behind the current design.
|
||||
|
||||
Provider registration
|
||||
---------------------
|
||||
|
||||
@@ -31,7 +35,7 @@ that the plugin will provide::
|
||||
The provider class
|
||||
------------------
|
||||
|
||||
.. class:: pretix.base.payment.BasePaymentProvider
|
||||
.. py:class:: pretix.base.payment.BasePaymentProvider
|
||||
|
||||
The central object of each payment provider is the subclass of ``BasePaymentProvider``.
|
||||
|
||||
@@ -54,55 +58,63 @@ The provider class
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: is_enabled
|
||||
.. autoattribute:: public_name
|
||||
|
||||
.. automethod:: calculate_fee
|
||||
.. autoattribute:: is_enabled
|
||||
|
||||
.. autoattribute:: settings_form_fields
|
||||
|
||||
.. automethod:: settings_form_clean
|
||||
|
||||
.. automethod:: settings_content_render
|
||||
|
||||
.. automethod:: render_invoice_text
|
||||
.. automethod:: is_allowed
|
||||
|
||||
.. automethod:: payment_form_render
|
||||
|
||||
.. automethod:: payment_form
|
||||
|
||||
.. automethod:: is_allowed
|
||||
|
||||
.. autoattribute:: payment_form_fields
|
||||
|
||||
.. automethod:: checkout_prepare
|
||||
|
||||
.. automethod:: payment_is_valid_session
|
||||
|
||||
.. automethod:: checkout_prepare
|
||||
|
||||
.. automethod:: checkout_confirm_render
|
||||
|
||||
This is an abstract method, you **must** override this!
|
||||
|
||||
.. automethod:: payment_perform
|
||||
.. automethod:: execute_payment
|
||||
|
||||
.. automethod:: calculate_fee
|
||||
|
||||
.. automethod:: order_pending_mail_render
|
||||
|
||||
.. automethod:: order_pending_render
|
||||
.. automethod:: payment_pending_render
|
||||
|
||||
This is an abstract method, you **must** override this!
|
||||
.. autoattribute:: abort_pending_allowed
|
||||
|
||||
.. automethod:: render_invoice_text
|
||||
|
||||
.. automethod:: order_change_allowed
|
||||
|
||||
.. automethod:: order_can_retry
|
||||
.. automethod:: payment_prepare
|
||||
|
||||
.. automethod:: order_prepare
|
||||
.. automethod:: payment_control_render
|
||||
|
||||
.. automethod:: order_paid_render
|
||||
.. automethod:: payment_refund_supported
|
||||
|
||||
.. automethod:: order_control_render
|
||||
.. automethod:: payment_partial_refund_supported
|
||||
|
||||
.. automethod:: order_control_refund_render
|
||||
.. automethod:: execute_refund
|
||||
|
||||
.. automethod:: order_control_refund_perform
|
||||
.. automethod:: shred_payment_info
|
||||
|
||||
.. automethod:: is_implicit
|
||||
.. autoattribute:: is_implicit
|
||||
|
||||
.. autoattribute:: is_meta
|
||||
|
||||
.. autoattribute:: test_mode_message
|
||||
|
||||
|
||||
Additional views
|
||||
|
||||
129
doc/development/api/payment_2.0.rst
Normal file
129
doc/development/api/payment_2.0.rst
Normal file
@@ -0,0 +1,129 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
.. _`payment2.0`:
|
||||
|
||||
Porting a payment provider from pretix 1.x to pretix 2.x
|
||||
========================================================
|
||||
|
||||
In pretix 2.x, we changed large parts of the payment provider API. This documentation details the changes we made
|
||||
and shows you how you can make an existing pretix 1.x payment provider compatible with pretix 2.x
|
||||
|
||||
Conceptual overview
|
||||
-------------------
|
||||
|
||||
In pretix 1.x, an order was always directly connected to a payment provider for the full life of an order. As long as
|
||||
an order was unpaid, this could still be changed in some cases, but once an order was paid, no changes to the payment
|
||||
provider were possible any more. Additionally, the internal state of orders allowed orders only to be fully paid or
|
||||
not paid at all. This leads to a couple of consequences:
|
||||
|
||||
* Payment-related functions (like "execute payment" or "do a refund") always operated on full orders.
|
||||
|
||||
* Changing the total of an order was basically impossible once an order was paid, since there was no concept of
|
||||
partial payments or partial refunds.
|
||||
|
||||
* Payment provider plugins needed to take complicated steps to detect cases that require human intervention, like e.g.
|
||||
|
||||
* An order has expired, no quota is left to revive it, but a payment has been received
|
||||
|
||||
* A payment has been received for a canceled order
|
||||
|
||||
* A payment has been received for an order that has already been paid with a different payment method
|
||||
|
||||
* An external payment service notified us of a refund/dispute
|
||||
|
||||
We noticed that we copied and repeated large portions of code in all our official payment provider plugins, just
|
||||
to deal with some of these cases.
|
||||
|
||||
* Sometimes, there is the need to mark an order as refunded within pretix, without automatically triggering a refund
|
||||
with an external API. Every payment method needed to implement a user interface for this independently.
|
||||
|
||||
* If a refund was not possible automatically, there was no way user to track which payments actually have been refunded
|
||||
manually and which are still left to do.
|
||||
|
||||
* When the payment with one payment provider failed and the user changed to a different payment provider, all
|
||||
information about the first payment was lost from the order object and could only be retrieved from order log data,
|
||||
which also made it hard to design a data shredder API to get rid of this data.
|
||||
|
||||
In pretix 2.x, we introduced two new models, :py:class:`OrderPayment <pretix.base.models.OrderPayment>` and
|
||||
:py:class:`OrderRefund <pretix.base.models.OrderRefund>`. Each instance of these is connected to an order and
|
||||
represents one single attempt to pay or refund a specific amount of money. Each one of these has an individual state,
|
||||
can individually fail or succeed, and carries an amount variable that can differ from the order total.
|
||||
|
||||
This has the following advantages:
|
||||
|
||||
* The system can now detect orders that are over- or underpaid, independent of the payment providers in use.
|
||||
|
||||
* Therefore, we can now allow partial payments, partial refunds, and changing paid orders, and automatically detect
|
||||
the cases listed above and notify the user.
|
||||
|
||||
Payment providers now interact with those payment and refund objects more than with orders.
|
||||
|
||||
Your to-do list
|
||||
---------------
|
||||
|
||||
Payment processing
|
||||
""""""""""""""""""
|
||||
|
||||
* The method ``BasePaymentProvider.order_pending_render`` has been removed and replaced by a new
|
||||
``BasePaymentProvider.payment_pending_render(request, payment)`` method that is passed an ``OrderPayment``
|
||||
object instead of an ``Order``.
|
||||
|
||||
* The method ``BasePaymentProvider.payment_form_render`` now receives a new ``total`` parameter.
|
||||
|
||||
* The method ``BasePaymentProvider.payment_perform`` has been removed and replaced by a new method
|
||||
``BasePaymentProvider.execute_payment(request, payment)`` that is passed an ``OrderPayment``
|
||||
object instead of an ``Order``.
|
||||
|
||||
* The function ``pretix.base.services.mark_order_paid`` has been removed, instead call ``payment.confirm()``
|
||||
on a pending ``OrderPayment`` object. If no further payments are required for this order, this will also
|
||||
mark the order as paid automatically. Note that ``payment.confirm()`` can still throw a ``QuotaExceededException``,
|
||||
however it will still mark the payment as complete (not the order!), so you should catch this exception and
|
||||
inform the user, but not abort the transaction.
|
||||
|
||||
* A new property ``BasePaymentProvider.abort_pending_allowed`` has been introduced. Only if set, the user will
|
||||
be able to retry a payment or switch the payment method when the order currently has a payment object in
|
||||
state ``"pending"``. This replaces ``BasePaymentProvider.order_can_retry``, which no longer exists.
|
||||
|
||||
* The methods ``BasePaymentProvider.retry_prepare`` and ``BasePaymentProvider.order_prepare`` have both been
|
||||
replaced by a new method ``BasePaymentProvider.payment_prepare(request, payment)`` that is passed an ``OrderPayment``
|
||||
object instead of an ``Order``. **Keep in mind that this payment object might have an amount property that
|
||||
differs from the order total, if the order is already partially paid.**
|
||||
|
||||
* The method ``BasePaymentProvider.order_paid_render`` has been removed.
|
||||
|
||||
* The method ``BasePaymentProvider.order_control_render`` has been removed and replaced by a new method
|
||||
``BasePaymentProvider.payment_control_render(request, payment)`` that is passed an ``OrderPayment``
|
||||
object instead of an ``Order``.
|
||||
|
||||
* There's no need to manually deal with excess payments or duplicate payments anymore, just setting the ``OrderPayment``
|
||||
methods to the correct state will do the job.
|
||||
|
||||
Creating refunds
|
||||
""""""""""""""""
|
||||
|
||||
* The methods ``BasePaymentProvider.order_control_refund_render`` and ``BasePaymentProvider.order_control_refund_perform``
|
||||
have been removed.
|
||||
|
||||
* Two new boolean methods ``BasePaymentProvider.payment_refund_supported(payment)`` and ``BasePaymentProvider.payment_partial_refund_supported(payment)``
|
||||
have been introduced. They should be set to return ``True`` if and only if the payment API allows to *automatically*
|
||||
transfer the money back to the customer.
|
||||
|
||||
* A new method ``BasePaymentProvider.execute_refund(refund)`` has been introduced. This method is called using a
|
||||
``OrderRefund`` object in ``"created"`` state and is expected to transfer the money back and confirm success with
|
||||
calling ``refund.done()``. This will only ever be called if either ``BasePaymentProvider.payment_refund_supported(payment)``
|
||||
or ``BasePaymentProvider.payment_partial_refund_supported(payment)`` return ``True``.
|
||||
|
||||
Processing external refunds
|
||||
"""""""""""""""""""""""""""
|
||||
|
||||
* If e.g. a webhook API notifies you that a payment has been disputed or refunded with the external API, you are
|
||||
expected to call ``OrderPayment.create_external_refund(self, amount, execution_date, info='{}')`` on this payment.
|
||||
This will create and return an appropriate ``OrderRefund`` object and send out a notification. However, it will not
|
||||
mark the order as refunded, but will ask the event organizer for a decision.
|
||||
|
||||
Data shredders
|
||||
""""""""""""""
|
||||
|
||||
* The method ``BasePaymentProvider.shred_payment_info`` is no longer passed an order, but instead **either**
|
||||
an ``OrderPayment`` **or** an ``OrderRefund``.
|
||||
@@ -79,6 +79,9 @@ human-readable error messages. We recommend using the ``django.utils.functional.
|
||||
decorator, as it might get called a lot. You can also implement ``compatibility_warnings``,
|
||||
those will be displayed but not block the plugin execution.
|
||||
|
||||
The ``AppConfig`` class may implement a method ``is_available(event)`` that checks if a plugin
|
||||
is available for a specific event. If not, it will not be shown in the plugin list of that event.
|
||||
|
||||
Plugin registration
|
||||
-------------------
|
||||
|
||||
|
||||
125
doc/development/api/quality.rst
Normal file
125
doc/development/api/quality.rst
Normal file
@@ -0,0 +1,125 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
.. _`pluginquality`:
|
||||
|
||||
Plugin quality checklist
|
||||
========================
|
||||
|
||||
If you want to write a high-quality pretix plugin, this is a list of things you should check before
|
||||
you publish it. This is also a list of things that we check, if we consider installing an externally
|
||||
developed plugin on our hosted infrastructure.
|
||||
|
||||
A. Meta
|
||||
-------
|
||||
|
||||
#. The plugin is clearly licensed under an appropriate license.
|
||||
|
||||
#. The plugin has an unambiguous name, description, and author metadata.
|
||||
|
||||
#. The plugin has a clear versioning scheme and the latest version of the plugin is kept compatible to the latest
|
||||
stable version of pretix.
|
||||
|
||||
#. The plugin is properly packaged using standard Python packaging tools.
|
||||
|
||||
#. The plugin correctly declares its external dependencies.
|
||||
|
||||
#. A contact address is provided in case of security issues.
|
||||
|
||||
B. Isolation
|
||||
------------
|
||||
|
||||
#. If any signal receivers use the `dispatch_uid`_ feature, the UIDs are prefixed by the plugin's name and do not
|
||||
clash with other plugins.
|
||||
|
||||
#. If any templates or static files are shipped, they are located in subdirectories with the name of the plugin and do
|
||||
not clash with other plugins or core files.
|
||||
|
||||
#. Any keys stored to the settings store are prefixed with the plugin's name and do not clash with other plugins or
|
||||
core.
|
||||
|
||||
#. Any keys stored to the user session are prefixed with the plugin's name and do not clash with other plugins or
|
||||
core.
|
||||
|
||||
#. Any registered URLs are unlikely to clash with other plugins or future core URLs.
|
||||
|
||||
C. Security
|
||||
-----------
|
||||
|
||||
#. All important actions are logged to the :ref:`shared log storage <logging>` and a signal receiver is registered to
|
||||
provide a human-readable representation of the log entry.
|
||||
|
||||
#. All views require appropriate permissions and use the ``event_urls`` mechanism if appropriate.
|
||||
:ref:`Read more <customview>`
|
||||
|
||||
#. Any session data for customers is stored in the cart session system if appropriate.
|
||||
|
||||
#. If the plugin is a payment provider:
|
||||
|
||||
#. No credit card numbers may be stored within pretix.
|
||||
|
||||
#. A notification/webhook system is implemented to notify pretix of any refunds.
|
||||
|
||||
#. If such a webhook system is implemented, contents of incoming webhooks are either verified using a cryptographic
|
||||
signature or are not being trusted and all data is fetched from an API instead.
|
||||
|
||||
D. Privacy
|
||||
----------
|
||||
|
||||
#. No personal data is stored that is not required for the plugin's functionality.
|
||||
|
||||
#. For any personal data that is saved to the database, an appropriate :ref:`data shredder <shredder>` is provided
|
||||
that offers the data for download and then removes it from the database (including log entries).
|
||||
|
||||
E. Internationalization
|
||||
-----------------------
|
||||
|
||||
#. All user-facing strings in templates, Python code, and templates are wrapped in `gettext calls`_.
|
||||
|
||||
#. No languages, time zones, date formats, or time formats are hardcoded.
|
||||
|
||||
#. Installing the plugin automatically compiles ``.po`` files to ``.mo`` files. This is fulfilled automatically if
|
||||
you use the ``setup.py`` file form our plugin cookiecutter.
|
||||
|
||||
F. Functionality
|
||||
----------------
|
||||
|
||||
#. If the plugin adds any database models or relationships from the settings storage to database models, it registers
|
||||
a receiver to the :py:attr:`pretix.base.signals.event_copy_data` or :py:attr:`pretix.base.signals.item_copy_data`
|
||||
signals.
|
||||
|
||||
#. If the plugin is a payment provider:
|
||||
|
||||
#. A webhook-like system is implemented if payment confirmations are not sent instantly.
|
||||
|
||||
#. Refunds are implemented, if possible.
|
||||
|
||||
#. In case of overpayment or external refunds, a "required action" is created to notify the event organizer.
|
||||
|
||||
#. If the plugin adds steps to the checkout process, it has been tested in combination with the pretix widget.
|
||||
|
||||
G. Code quality
|
||||
---------------
|
||||
|
||||
#. `isort`_ and `flake8`_ are used to ensure consistent code styling.
|
||||
|
||||
#. Unit tests are provided for important pieces of business logic.
|
||||
|
||||
#. Functional tests are provided for important interface parts.
|
||||
|
||||
#. Tests are provided to check that permission checks are working.
|
||||
|
||||
#. Continuous Integration is set up to check that tests are passing and styling is consistent.
|
||||
|
||||
H. Specific to pretix.eu
|
||||
------------------------
|
||||
|
||||
#. pretix.eu integrates the data stored by this plugin with its data report features.
|
||||
|
||||
#. pretix.eu integrates this plugin in its generated privacy statements, if necessary.
|
||||
|
||||
|
||||
.. _isort: https://www.google.de/search?q=isort&oq=isort&aqs=chrome..69i57j0j69i59j69i60l2j69i59.599j0j4&sourceid=chrome&ie=UTF-8
|
||||
.. _flake8: http://flake8.pycqa.org/en/latest/
|
||||
.. _gettext calls: https://docs.djangoproject.com/en/2.0/topics/i18n/translation/
|
||||
.. _dispatch_uid: https://docs.djangoproject.com/en/2.0/topics/signals/#django.dispatch.Signal.connect
|
||||
94
doc/development/api/shredder.rst
Normal file
94
doc/development/api/shredder.rst
Normal file
@@ -0,0 +1,94 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
.. _`shredder`:
|
||||
|
||||
Writing a data shredder
|
||||
=======================
|
||||
|
||||
If your plugin adds the ability to store personal data within pretix, you should also implement a "data shredder"
|
||||
to anonymize or pseudonymize the data later.
|
||||
|
||||
Shredder registration
|
||||
---------------------
|
||||
|
||||
The data shredder API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available data shredders. Your plugin
|
||||
should listen for this signal and return the subclass of ``pretix.base.shredder.BaseDataShredder``
|
||||
that we'll provide in this plugin:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.signals import register_data_shredders
|
||||
|
||||
|
||||
@receiver(register_data_shredders, dispatch_uid="custom_data_shredders")
|
||||
def register_shredder(sender, **kwargs):
|
||||
return [
|
||||
PluginDataShredder,
|
||||
]
|
||||
|
||||
The shredder class
|
||||
------------------
|
||||
|
||||
.. class:: pretix.base.shredder.BaseDataShredder
|
||||
|
||||
The central object of each invoice renderer is the subclass of ``BaseInvoiceRenderer``.
|
||||
|
||||
.. py:attribute:: BaseInvoiceRenderer.event
|
||||
|
||||
The default constructor sets this property to the event we are currently
|
||||
working for.
|
||||
|
||||
.. autoattribute:: identifier
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: verbose_name
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: description
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. automethod:: generate_files
|
||||
|
||||
.. automethod:: shred_data
|
||||
|
||||
Example
|
||||
-------
|
||||
|
||||
For example, the core data shredder responsible for removing invoice address information including their history
|
||||
looks like this:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
class InvoiceAddressShredder(BaseDataShredder):
|
||||
verbose_name = _('Invoice addresses')
|
||||
identifier = 'invoice_addresses'
|
||||
description = _('This will remove all invoice addresses from orders, '
|
||||
'as well as logged changes to them.')
|
||||
|
||||
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||
yield 'invoice-addresses.json', 'application/json', json.dumps({
|
||||
ia.order.code: InvoiceAdddressSerializer(ia).data
|
||||
for ia in InvoiceAddress.objects.filter(order__event=self.event)
|
||||
}, indent=4)
|
||||
|
||||
@transaction.atomic
|
||||
def shred_data(self):
|
||||
InvoiceAddress.objects.filter(order__event=self.event).delete()
|
||||
|
||||
for le in self.event.logentry_set.filter(action_type="pretix.event.order.modified"):
|
||||
d = le.parsed_data
|
||||
if 'invoice_data' in d and not isinstance(d['invoice_data'], bool):
|
||||
for field in d['invoice_data']:
|
||||
if d['invoice_data'][field]:
|
||||
d['invoice_data'][field] = '█'
|
||||
le.data = json.dumps(d)
|
||||
le.shredded = True
|
||||
le.save(update_fields=['data', 'shredded'])
|
||||
|
||||
@@ -82,6 +82,12 @@ Orders
|
||||
^^^^^^
|
||||
|
||||
If a customer completes the checkout process, an **Order** will be created containing all the entered information.
|
||||
An order can be in one of currently five states that are listed in the diagram below:
|
||||
An order can be in one of currently four states that are listed in the diagram below:
|
||||
|
||||
.. image:: /images/order_states.png
|
||||
|
||||
There are additional "fake" states that are displayed like states but not represented as states in the system:
|
||||
|
||||
* An order is considered **canceled (with paid fee)** if it is in **paid** status but does not include any non-cancelled positions.
|
||||
|
||||
* An order is considered **requiring approval** if it is in **pending** status with the ``require_approval`` attribute set to ``True``.
|
||||
|
||||
@@ -4,6 +4,8 @@ Logging and notifications
|
||||
As pretix is handling monetary transactions, we are very careful to make it possible to review all changes
|
||||
in the system that lead to the current state.
|
||||
|
||||
.. _`logging`:
|
||||
|
||||
Logging changes
|
||||
---------------
|
||||
|
||||
|
||||
@@ -86,6 +86,15 @@ Carts and Orders
|
||||
.. autoclass:: pretix.base.models.OrderPosition
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.OrderFee
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.OrderPayment
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.OrderRefund
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.CartPosition
|
||||
:members:
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ External Dependencies
|
||||
---------------------
|
||||
Your should install the following on your system:
|
||||
|
||||
* Python 3.4 or newer
|
||||
* Python 3.5 or newer
|
||||
* ``pip`` for Python 3 (Debian package: ``python3-pip``)
|
||||
* ``python-dev`` for Python 3 (Debian package: ``python3-dev``)
|
||||
* ``libffi`` (Debian package: ``libffi-dev``)
|
||||
@@ -54,10 +54,6 @@ The first thing you need are all the main application's dependencies::
|
||||
cd src/
|
||||
pip3 install -r requirements.txt -r requirements/dev.txt
|
||||
|
||||
If you are working with Python 3.4, you will also need (you can skip this for Python 3.5+)::
|
||||
|
||||
pip3 install -r requirements/py34.txt
|
||||
|
||||
Next, you need to copy the SCSS files from the source folder to the STATIC_ROOT directory::
|
||||
|
||||
python manage.py collectstatic --noinput
|
||||
@@ -115,12 +111,21 @@ Execute the following command to run pretix' test suite (might take a couple of
|
||||
``NUM`` being the number of threads you want to use.
|
||||
|
||||
It is a good idea to put this command into your git hook ``.git/hooks/pre-commit``,
|
||||
for example::
|
||||
for example, to check for any errors in any staged files when committing::
|
||||
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
cd $GIT_DIR/../src
|
||||
flake8 . || exit 1
|
||||
isort -q -rc -c . || exit 1
|
||||
export GIT_WORK_TREE=../
|
||||
export GIT_DIR=../.git
|
||||
source ../env/bin/activate # Adjust to however you activate your virtual environment
|
||||
for file in $(git diff --cached --name-only | grep -E '\.py$' | grep -Ev "migrations|mt940\.py|pretix/settings\.py|make_testdata\.py|testutils/settings\.py|tests/settings\.py|pretix/base/models/__init__\.py")
|
||||
do
|
||||
echo $file
|
||||
git show ":$file" | flake8 - --stdin-display-name="$file" || exit 1 # we only want to lint the staged changes, not any un-staged changes
|
||||
git show ":$file" | isort -df --check-only - | grep ERROR && exit 1 || true
|
||||
done
|
||||
|
||||
|
||||
|
||||
This keeps you from accidentally creating commits violating the style guide.
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 53 KiB |
@@ -4,7 +4,6 @@ Pending: Order is expecting payment\nOrder reduces quotas
|
||||
Expired: Payment period is over\nOrder does not affect quotas
|
||||
Paid: Order was successful\nOrder reduces quotas
|
||||
Canceled: Order has been canceled\nOrder does not affect quotas
|
||||
Refunded: Order has been refunded\nOrder does not affect quotas
|
||||
|
||||
[*] --> Pending: customer\nplaces order
|
||||
Pending --> Paid: successful payment
|
||||
@@ -12,8 +11,9 @@ Pending --> Expired: automatically\nor manually\non admin action
|
||||
Expired --> Paid: if payment is received\nonly if quota left
|
||||
Expired --> Canceled
|
||||
Expired --> Pending: manually\non admin action
|
||||
Paid --> Refunded: manually on\nadmin action\nor if an external\npayment provider\nnotifies about a\npayment refund
|
||||
Paid --> Canceled: manually on\nadmin action\nor if an external\npayment provider\nnotifies about a\npayment refund
|
||||
Pending --> Canceled: on admin or\ncustomer action
|
||||
Paid -> Pending: manually on admin action
|
||||
[*] --> Paid: customer\nplaces free order
|
||||
|
||||
@enduml
|
||||
|
||||
110
doc/plugins/badges.rst
Normal file
110
doc/plugins/badges.rst
Normal file
@@ -0,0 +1,110 @@
|
||||
Badges
|
||||
======
|
||||
|
||||
The badges plugin provides a HTTP API that exposes the various layouts used to generate PDF badges.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The badge layout resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal layout ID
|
||||
name string Internal layout description
|
||||
default boolean ``true`` if this is the default layout
|
||||
layout object Layout specification for libpretixprint
|
||||
background URL Background PDF file
|
||||
item_assignments list of objects Products this layout is assigned to
|
||||
└ item integer Item ID
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.16
|
||||
|
||||
This resource has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/badgelayouts/
|
||||
|
||||
Returns a list of all badge layouts
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/democon/badgelayouts/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": {…},
|
||||
"background": {},
|
||||
"item_assignments": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of a valid event
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/badgelayouts/(id)/
|
||||
|
||||
Returns information on layout.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/democon/layoutsbadge/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": {…},
|
||||
"background": {},
|
||||
"item_assignments": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the layout 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.
|
||||
@@ -12,3 +12,5 @@ If you want to **create** a plugin, please go to the
|
||||
list
|
||||
pretixdroid
|
||||
banktransfer
|
||||
ticketoutputpdf
|
||||
badges
|
||||
|
||||
@@ -4,10 +4,10 @@ pretixdroid HTTP API
|
||||
The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_
|
||||
uses to communicate with the pretix server.
|
||||
|
||||
.. warning:: This API is intended **only** to serve the pretixdroid Android app. There are no backwards compatibility
|
||||
guarantees on this API. We will not add features that are not required for the Android App. There is a
|
||||
general-purpose :ref:`rest-api` that not yet provides all features that this API provides, but will do
|
||||
so in the future.
|
||||
.. warning:: This API is **DEPRECATED** and will probably go away soon. It is used **only** to serve the pretixdroid
|
||||
Android app. There are no backwards compatibility guarantees on this API. We will not add features that
|
||||
are not required for the Android App. There is a general-purpose :ref:`rest-api` that provides all
|
||||
features that you need to check in.
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
@@ -81,6 +81,7 @@ uses to communicate with the pretix server.
|
||||
"attention": false,
|
||||
"redeemed": true,
|
||||
"checkin_allowed": true,
|
||||
"addons_text": "Parking spot",
|
||||
"paid": true
|
||||
}
|
||||
}
|
||||
@@ -106,6 +107,7 @@ uses to communicate with the pretix server.
|
||||
"attention": false,
|
||||
"redeemed": true,
|
||||
"checkin_allowed": true,
|
||||
"addons_text": "Parking spot",
|
||||
"paid": true
|
||||
},
|
||||
"questions": [
|
||||
@@ -152,6 +154,7 @@ uses to communicate with the pretix server.
|
||||
"attention": false,
|
||||
"redeemed": true,
|
||||
"checkin_allowed": true,
|
||||
"addons_text": "Parking spot",
|
||||
"paid": true
|
||||
}
|
||||
}
|
||||
@@ -212,6 +215,7 @@ uses to communicate with the pretix server.
|
||||
"redeemed": false,
|
||||
"attention": false,
|
||||
"checkin_allowed": true,
|
||||
"addons_text": "Parking spot",
|
||||
"paid": true
|
||||
},
|
||||
...
|
||||
|
||||
116
doc/plugins/ticketoutputpdf.rst
Normal file
116
doc/plugins/ticketoutputpdf.rst
Normal file
@@ -0,0 +1,116 @@
|
||||
PDF ticket output
|
||||
=================
|
||||
|
||||
The PDF ticket output plugin provides a HTTP API that exposes the various layouts used
|
||||
to generate PDF tickets.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The ticket layout resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal layout ID
|
||||
name string Internal layout description
|
||||
default boolean ``true`` if this is the default layout
|
||||
layout object Layout specification for libpretixprint
|
||||
background URL Background PDF file
|
||||
item_assignments list of objects Products this layout is assigned to
|
||||
├ sales_channel string Sales channel (defaults to ``web``).
|
||||
└ item integer Item ID
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.16
|
||||
|
||||
This resource has been added.
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
The ``item_assignments.sales_channel`` field has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/
|
||||
|
||||
Returns a list of all ticket layouts
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/democon/ticketlayouts/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": {…},
|
||||
"background": {},
|
||||
"item_assignments": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of a valid event
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/(id)/
|
||||
|
||||
Returns information on layout.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/democon/ticketlayouts/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": {…},
|
||||
"background": {},
|
||||
"item_assignments": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the layout 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.
|
||||
BIN
doc/screens/organizer/webhook_edit.png
Normal file
BIN
doc/screens/organizer/webhook_edit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
doc/screens/organizer/webhook_list.png
Normal file
BIN
doc/screens/organizer/webhook_list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -1,9 +1,12 @@
|
||||
addon
|
||||
addons
|
||||
Analytics
|
||||
anonymize
|
||||
api
|
||||
auditability
|
||||
auth
|
||||
autobuild
|
||||
availabilities
|
||||
backend
|
||||
backends
|
||||
banktransfer
|
||||
@@ -16,10 +19,13 @@ checksum
|
||||
config
|
||||
contenttypes
|
||||
contextmanager
|
||||
cookiecutter
|
||||
cron
|
||||
cronjob
|
||||
cryptographic
|
||||
debian
|
||||
deduplication
|
||||
deprovision
|
||||
discoverable
|
||||
django
|
||||
dockerfile
|
||||
@@ -34,10 +40,13 @@ gettext
|
||||
gunicorn
|
||||
hardcoded
|
||||
hostname
|
||||
idempotency
|
||||
incrementing
|
||||
inofficial
|
||||
invalidations
|
||||
iterable
|
||||
Jimdo
|
||||
libpretixprint
|
||||
libsass
|
||||
linters
|
||||
memcached
|
||||
@@ -56,7 +65,9 @@ nginx
|
||||
NotificationType
|
||||
ons
|
||||
optimizations
|
||||
overpayment
|
||||
param
|
||||
passphrase
|
||||
percental
|
||||
positionid
|
||||
pre
|
||||
@@ -71,6 +82,8 @@ pretixpresale
|
||||
prometheus
|
||||
proxied
|
||||
proxying
|
||||
pseudonymize
|
||||
pseudonymization
|
||||
queryset
|
||||
redemptions
|
||||
redis
|
||||
@@ -79,6 +92,7 @@ regex
|
||||
renderer
|
||||
renderers
|
||||
reportlab
|
||||
SaaS
|
||||
screenshot
|
||||
selectable
|
||||
serializers
|
||||
@@ -95,9 +109,12 @@ subevent
|
||||
subevents
|
||||
submodule
|
||||
subpath
|
||||
Symfony
|
||||
systemd
|
||||
testmode
|
||||
testutils
|
||||
timestamp
|
||||
tuples
|
||||
un
|
||||
unconfigured
|
||||
unix
|
||||
@@ -106,6 +123,7 @@ untrusted
|
||||
username
|
||||
url
|
||||
versa
|
||||
versioning
|
||||
viewset
|
||||
viewsets
|
||||
webhook
|
||||
|
||||
@@ -107,6 +107,13 @@ voucher's settings.
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
Disabling the voucher input
|
||||
---------------------------
|
||||
|
||||
If you want to disable voucher input in the widget, you can pass the ``disable-vouchers`` attribute::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" disable-vouchers></pretix-widget>
|
||||
|
||||
pretix Button
|
||||
-------------
|
||||
|
||||
@@ -136,14 +143,107 @@ resources. Then, instead of the ``pretix-widget`` tag, use the ``pretix-button``
|
||||
As you can see, the ``pretix-button`` element takes an additional ``items`` attribute that specifies the items that
|
||||
should be added to the cart. The syntax of this attribute is ``item_ITEMID=1,item_ITEMID=2,variation_ITEMID_VARID=4``
|
||||
where ``ITEMID`` are the internal IDs of items to be added and ``VARID`` are the internal IDs of variations of those
|
||||
items, if the items have variations.
|
||||
items, if the items have variations. If you omit the ``items`` attribute, the general start page will be presented.
|
||||
|
||||
Just as the widget, the button supports the optional attributes ``voucher`` and ``skip-ssl-check``.
|
||||
|
||||
You can style the button using the ``pretix-button`` CSS class.
|
||||
|
||||
.. versionchanged:: 1.13
|
||||
Dynamically loading the widget
|
||||
------------------------------
|
||||
|
||||
The pretix Button has been added in version 1.13.
|
||||
If you need to control the way or timing the widget loads, for example because you want to modify user data (see
|
||||
below) dynamically via JavaScript, you can register a listener that we will call before creating the widget::
|
||||
|
||||
<script type="text/javascript">
|
||||
window.pretixWidgetCallback = function () {
|
||||
// Will be run before we create the widget.
|
||||
}
|
||||
</script>
|
||||
|
||||
If you want, you can suppress us loading the widget and/or modify the user data passed to the widget::
|
||||
|
||||
<script type="text/javascript">
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.PretixWidget.widget_data["email"] = "test@example.org";
|
||||
}
|
||||
</script>
|
||||
|
||||
If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``.
|
||||
|
||||
|
||||
Passing user data to the widget
|
||||
-------------------------------
|
||||
|
||||
If you display the widget in a restricted area of your website and you want to pre-fill fields in the checkout process
|
||||
with known user data to save your users some typing and increase conversions, you can pass additional data attributes
|
||||
with that information::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/"
|
||||
data-attendee-name-given-name="John"
|
||||
data-attendee-name-family-name="Doe"
|
||||
data-invoice-address-name-given-name="John"
|
||||
data-invoice-address-name-family-name="Doe"
|
||||
data-email="test@example.org"
|
||||
data-question-L9G8NG9M="Foobar">
|
||||
</pretix-widget>
|
||||
|
||||
This works for the pretix Button as well. Currently, the following attributes are understood by pretix itself:
|
||||
|
||||
* ``data-email`` will pre-fill the order email field as well as the attendee email field (if enabled).
|
||||
|
||||
* ``data-question-IDENTIFIER`` will pre-fill the answer for the question with the given identifier. You can view and set
|
||||
identifiers in the *Questions* section of the backend.
|
||||
|
||||
* Depending on the person name scheme configured in your event settings, you can pass one or more of
|
||||
``data-attendee-name-full-name``, ``data-attendee-name-given-name``, ``data-attendee-name-family-name``,
|
||||
``data-attendee-name-middle-name``, ``data-attendee-name-title``, ``data-attendee-name-calling-name``,
|
||||
``data-attendee-name-latin-transcription``. If you don't know or don't care, you can also just pass a string as
|
||||
``data-attendee-name``, which will pre-fill the last part of the name, whatever that is.
|
||||
|
||||
* ``data-invoice-address-FIELD`` will pre-fill the corresponding field of the invoice address. Possible values for
|
||||
``FIELD`` are ``company``, ``street``, ``zipcode``, ``city`` and ``country``, as well as fields specified by the
|
||||
naming scheme such as ``name-title`` or ``name-given-name`` (see above). ``country`` expects a two-character
|
||||
country code.
|
||||
|
||||
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
|
||||
Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
|
||||
* If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders
|
||||
made through this widget will be counted towards this campaign.
|
||||
|
||||
* If you use the tracking plugin, you can pass a Google Analytics User ID to enable cross-domain tracking. This will
|
||||
require you to dynamically load the widget, like this::
|
||||
|
||||
<script>
|
||||
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
||||
|
||||
ga('create', 'UA-XXXXXX-1', 'auto');
|
||||
ga('send', 'pageview');
|
||||
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||
if(window.ga && ga.create) {
|
||||
ga(function(tracker) {
|
||||
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
|
||||
window.PretixWidget.buildWidgets()
|
||||
});
|
||||
} else { // Tracking is probably blocked
|
||||
window.PretixWidget.buildWidgets()
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
Data passing options have been added in pretix 2.3. If you use a self-hosted version of pretix, they only work
|
||||
fully if you configured a redis server.
|
||||
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
|
||||
@@ -4,22 +4,10 @@ FAQ and Troubleshooting
|
||||
How can I test my shop before taking it live?
|
||||
---------------------------------------------
|
||||
|
||||
There are multiple ways to do this.
|
||||
|
||||
First, you could just create some orders in your real shop and cancel/refund them later. If you don't want to process
|
||||
real payments for the tests, you can either use a "manual" payment method like bank transfer and just mark the orders
|
||||
as paid with the button in the backend, or if you want to use e.g. Stripe, you can configure pretix to use your keys
|
||||
for the Stripe test system and use their test credit cars. Read our :ref:`Stripe documentation <stripe>` for more
|
||||
information.
|
||||
|
||||
Second, you could create a separate event, just for testing. In the last step of the :ref:`event creation process <event_create>`,
|
||||
you can specify that you want to copy all settings from your real event, so you don't have to do all of it twice.
|
||||
|
||||
We are planning to add a dedicated test mode in a later version of pretix.
|
||||
|
||||
If you are using the hosted service at pretix.eu and want to get rid of the test orders completely, contact us at
|
||||
support@pretix.eu and we can remove them for you. Please note that we only are able to do that *before* you have
|
||||
received any real orders (i.e. taken the shop public). We won't charge any fees for test orders or test events.
|
||||
On your event dashboard, click on the first tile that shows your shop status. On the lower part of this page, you can
|
||||
place your event into "test mode". In "test mode", everything behaves the same, but orders created during test mode can
|
||||
later be fully deleted. Be sure to actually delete them when or after you turn off test mode, since test mode orders
|
||||
still count toward your quotas and are included in your reports.
|
||||
|
||||
How do I delete an event?
|
||||
-------------------------
|
||||
|
||||
6
readthedocs.yml
Normal file
6
readthedocs.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
build:
|
||||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.6
|
||||
@@ -1,12 +0,0 @@
|
||||
[run]
|
||||
source = pretix
|
||||
omit = */migrations/*,*/urls.py,*/tests/*,*/testdummy/*,*/admin.py,pretix/wsgi.py,pretix/settings.py
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __str__
|
||||
der __repr__
|
||||
if settings.DEBUG
|
||||
NOQA
|
||||
NotImplementedError
|
||||
@@ -34,4 +34,5 @@ git push
|
||||
# Unlock Weblate
|
||||
for c in $COMPONENTS; do
|
||||
wlc unlock $c;
|
||||
wlc pull $c;
|
||||
done
|
||||
|
||||
@@ -8,6 +8,8 @@ recursive-include pretix/control/templates *
|
||||
recursive-include pretix/presale/templates *
|
||||
recursive-include pretix/plugins/banktransfer/templates *
|
||||
recursive-include pretix/plugins/banktransfer/static *
|
||||
recursive-include pretix/plugins/manualpayment/templates *
|
||||
recursive-include pretix/plugins/manualpayment/static *
|
||||
recursive-include pretix/plugins/paypal/templates *
|
||||
recursive-include pretix/plugins/pretixdroid/templates *
|
||||
recursive-include pretix/plugins/pretixdroid/static *
|
||||
@@ -18,3 +20,5 @@ recursive-include pretix/plugins/stripe/templates *
|
||||
recursive-include pretix/plugins/stripe/static *
|
||||
recursive-include pretix/plugins/ticketoutputpdf/templates *
|
||||
recursive-include pretix/plugins/ticketoutputpdf/static *
|
||||
recursive-include pretix/plugins/badges/templates *
|
||||
recursive-include pretix/plugins/badges/static *
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.0"
|
||||
__version__ = "2.5.0.dev0"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PretixApiConfig(AppConfig):
|
||||
name = 'pretix.api'
|
||||
label = 'pretixapi'
|
||||
|
||||
def ready(self):
|
||||
from . import signals, webhooks # noqa
|
||||
|
||||
|
||||
default_app_config = 'pretix.api.PretixApiConfig'
|
||||
|
||||
25
src/pretix/api/auth/device.py
Normal file
25
src/pretix/api/auth/device.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
|
||||
from pretix.base.models import Device
|
||||
|
||||
|
||||
class DeviceTokenAuthentication(TokenAuthentication):
|
||||
model = Device
|
||||
keyword = 'Device'
|
||||
|
||||
def authenticate_credentials(self, key):
|
||||
model = self.get_model()
|
||||
try:
|
||||
device = model.objects.select_related('organizer').get(api_token=key)
|
||||
except model.DoesNotExist:
|
||||
raise exceptions.AuthenticationFailed('Invalid token.')
|
||||
|
||||
if not device.initialized:
|
||||
raise exceptions.AuthenticationFailed('Device has not been initialized.')
|
||||
|
||||
if not device.api_token:
|
||||
raise exceptions.AuthenticationFailed('Device access has been revoked.')
|
||||
|
||||
return AnonymousUser(), device
|
||||
@@ -1,6 +1,7 @@
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
from pretix.base.models import Event
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.base.models import Device, Event
|
||||
from pretix.base.models.organizer import Organizer, TeamAPIToken
|
||||
from pretix.helpers.security import (
|
||||
SessionInvalid, SessionReauthRequired, assert_session_valid,
|
||||
@@ -8,10 +9,9 @@ from pretix.helpers.security import (
|
||||
|
||||
|
||||
class EventPermission(BasePermission):
|
||||
model = TeamAPIToken
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user.is_authenticated and not isinstance(request.auth, TeamAPIToken):
|
||||
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
|
||||
return False
|
||||
|
||||
if request.method not in SAFE_METHODS and hasattr(view, 'write_permission'):
|
||||
@@ -30,7 +30,7 @@ class EventPermission(BasePermission):
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
|
||||
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken)
|
||||
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
|
||||
else request.user)
|
||||
if 'event' in request.resolver_match.kwargs and 'organizer' in request.resolver_match.kwargs:
|
||||
request.event = Event.objects.filter(
|
||||
@@ -55,4 +55,28 @@ class EventPermission(BasePermission):
|
||||
|
||||
if required_permission and required_permission not in request.orgapermset:
|
||||
return False
|
||||
|
||||
if isinstance(request.auth, OAuthAccessToken):
|
||||
if not request.auth.allow_scopes(['write']) and request.method not in SAFE_METHODS:
|
||||
return False
|
||||
if not request.auth.allow_scopes(['read']) and request.method in SAFE_METHODS:
|
||||
return False
|
||||
if isinstance(request.auth, OAuthAccessToken) and hasattr(request, 'organizer'):
|
||||
if not request.auth.organizers.filter(pk=request.organizer.pk).exists():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class EventCRUDPermission(EventPermission):
|
||||
def has_permission(self, request, view):
|
||||
if not super(EventCRUDPermission, self).has_permission(request, view):
|
||||
return False
|
||||
elif view.action == 'create' and 'can_create_events' not in request.orgapermset:
|
||||
return False
|
||||
elif view.action == 'destroy' and 'can_change_event_settings' not in request.eventpermset:
|
||||
return False
|
||||
elif view.action in ['update', 'partial_update'] \
|
||||
and 'can_change_event_settings' not in request.eventpermset:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -10,7 +10,10 @@ def custom_exception_handler(exc, context):
|
||||
if isinstance(exc, LockTimeoutException):
|
||||
response = Response(
|
||||
{'detail': 'The server was too busy to process your request. Please try again.'},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
headers={
|
||||
'Retry-After': 5
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
128
src/pretix/api/migrations/0001_initial.py
Normal file
128
src/pretix/api/migrations/0001_initial.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.13 on 2018-06-04 11:19
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
import oauth2_provider.generators
|
||||
import oauth2_provider.validators
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OAuthAccessToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('token', models.CharField(max_length=255, unique=True)),
|
||||
('expires', models.DateTimeField()),
|
||||
('scope', models.TextField(blank=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OAuthApplication',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('client_type',
|
||||
models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)),
|
||||
('authorization_grant_type', models.CharField(
|
||||
choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'),
|
||||
('password', 'Resource owner password-based'),
|
||||
('client-credentials', 'Client credentials')], max_length=32)),
|
||||
('skip_authorization', models.BooleanField(default=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255, verbose_name='Application name')),
|
||||
('redirect_uris', models.TextField(help_text='Allowed URIs list, space separated',
|
||||
validators=[oauth2_provider.validators.URIValidator],
|
||||
verbose_name='Redirection URIs')),
|
||||
('client_id',
|
||||
models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100,
|
||||
unique=True, verbose_name='Client ID')),
|
||||
('client_secret',
|
||||
models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_secret,
|
||||
max_length=255, verbose_name='Client secret')),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='pretixapi_oauthapplication', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OAuthGrant',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('code', models.CharField(max_length=255, unique=True)),
|
||||
('expires', models.DateTimeField()),
|
||||
('redirect_uri', models.CharField(max_length=255)),
|
||||
('scope', models.TextField(blank=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
|
||||
('user',
|
||||
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pretixapi_oauthgrant',
|
||||
to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OAuthRefreshToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('token', models.CharField(max_length=255)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('revoked', models.DateTimeField(null=True)),
|
||||
('access_token',
|
||||
models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)),
|
||||
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='pretixapi_oauthrefreshtoken', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='oauthaccesstoken',
|
||||
name='application',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='oauthaccesstoken',
|
||||
name='source_refresh_token',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='refreshed_access_token',
|
||||
to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='oauthaccesstoken',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='pretixapi_oauthaccesstoken', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='oauthrefreshtoken',
|
||||
unique_together=set([('token', 'revoked')]),
|
||||
),
|
||||
]
|
||||
26
src/pretix/api/migrations/0002_auto_20180604_1120.py
Normal file
26
src/pretix/api/migrations/0002_auto_20180604_1120.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.13 on 2018-06-04 11:20
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0001_initial'),
|
||||
('pretixapi', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='oauthaccesstoken',
|
||||
name='organizers',
|
||||
field=models.ManyToManyField(to='pretixbase.Organizer'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='oauthgrant',
|
||||
name='organizers',
|
||||
field=models.ManyToManyField(to='pretixbase.Organizer'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 2.1.1 on 2018-11-07 10:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0102_auto_20181017_0024'),
|
||||
('pretixapi', '0002_auto_20180604_1120'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WebHook',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('enabled', models.BooleanField(default=True, verbose_name='Enable webhook')),
|
||||
('target_url', models.URLField(verbose_name='Target URL')),
|
||||
('all_events', models.BooleanField(default=False, verbose_name='All events (including newly created ones)')),
|
||||
('limit_events', models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events')),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Organizer')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WebHookCall',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('datetime', models.DateTimeField(auto_now_add=True)),
|
||||
('target_url', models.URLField()),
|
||||
('is_retry', models.BooleanField(default=False)),
|
||||
('execution_time', models.FloatField(null=True)),
|
||||
('return_code', models.PositiveIntegerField(default=0)),
|
||||
('payload', models.TextField()),
|
||||
('response_body', models.TextField()),
|
||||
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixapi.WebHook')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WebHookEventListener',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action_type', models.CharField(max_length=255)),
|
||||
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixapi.WebHook')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='webhookcall',
|
||||
name='success',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhook',
|
||||
name='all_events',
|
||||
field=models.BooleanField(default=True, verbose_name='All events (including newly created ones)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhook',
|
||||
name='organizer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhooks', to='pretixbase.Organizer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhookcall',
|
||||
name='webhook',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calls', to='pretixapi.WebHook'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhookeventlistener',
|
||||
name='webhook',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='listeners', to='pretixapi.WebHook'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='webhookcall',
|
||||
name='action_type',
|
||||
field=models.CharField(default='', max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
0
src/pretix/api/migrations/__init__.py
Normal file
0
src/pretix/api/migrations/__init__.py
Normal file
108
src/pretix/api/models.py
Normal file
108
src/pretix/api/models.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from oauth2_provider.generators import (
|
||||
generate_client_id, generate_client_secret,
|
||||
)
|
||||
from oauth2_provider.models import (
|
||||
AbstractAccessToken, AbstractApplication, AbstractGrant,
|
||||
AbstractRefreshToken,
|
||||
)
|
||||
from oauth2_provider.validators import URIValidator
|
||||
|
||||
|
||||
class OAuthApplication(AbstractApplication):
|
||||
name = models.CharField(verbose_name=_("Application name"), max_length=255, blank=False)
|
||||
redirect_uris = models.TextField(
|
||||
blank=False, validators=[URIValidator],
|
||||
verbose_name=_("Redirection URIs"),
|
||||
help_text=_("Allowed URIs list, space separated")
|
||||
)
|
||||
client_id = models.CharField(
|
||||
verbose_name=_("Client ID"),
|
||||
max_length=100, unique=True, default=generate_client_id, db_index=True
|
||||
)
|
||||
client_secret = models.CharField(
|
||||
verbose_name=_("Client secret"),
|
||||
max_length=255, blank=False, default=generate_client_secret, db_index=True
|
||||
)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("control:user.settings.oauth.app", kwargs={'pk': self.id})
|
||||
|
||||
def is_usable(self, request):
|
||||
return self.active and super().is_usable(request)
|
||||
|
||||
|
||||
class OAuthGrant(AbstractGrant):
|
||||
application = models.ForeignKey(
|
||||
OAuthApplication, on_delete=models.CASCADE
|
||||
)
|
||||
organizers = models.ManyToManyField('pretixbase.Organizer')
|
||||
|
||||
|
||||
class OAuthAccessToken(AbstractAccessToken):
|
||||
source_refresh_token = models.OneToOneField(
|
||||
# unique=True implied by the OneToOneField
|
||||
'OAuthRefreshToken', on_delete=models.SET_NULL, blank=True, null=True,
|
||||
related_name="refreshed_access_token"
|
||||
)
|
||||
application = models.ForeignKey(
|
||||
OAuthApplication, on_delete=models.CASCADE, blank=True, null=True,
|
||||
)
|
||||
organizers = models.ManyToManyField('pretixbase.Organizer')
|
||||
|
||||
def revoke(self):
|
||||
self.expires = now() - timedelta(hours=1)
|
||||
self.save(update_fields=['expires'])
|
||||
|
||||
|
||||
class OAuthRefreshToken(AbstractRefreshToken):
|
||||
application = models.ForeignKey(
|
||||
OAuthApplication, on_delete=models.CASCADE)
|
||||
access_token = models.OneToOneField(
|
||||
OAuthAccessToken, on_delete=models.SET_NULL, blank=True, null=True,
|
||||
related_name="refresh_token"
|
||||
)
|
||||
|
||||
|
||||
class WebHook(models.Model):
|
||||
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
|
||||
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
|
||||
target_url = models.URLField(verbose_name=_("Target URL"))
|
||||
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)
|
||||
|
||||
@property
|
||||
def action_types(self):
|
||||
return [
|
||||
l.action_type for l in self.listeners.all()
|
||||
]
|
||||
|
||||
|
||||
class WebHookEventListener(models.Model):
|
||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='listeners')
|
||||
action_type = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
ordering = ("action_type",)
|
||||
|
||||
|
||||
class WebHookCall(models.Model):
|
||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
|
||||
datetime = models.DateTimeField(auto_now_add=True)
|
||||
target_url = models.URLField()
|
||||
action_type = models.CharField(max_length=255)
|
||||
is_retry = models.BooleanField(default=False)
|
||||
execution_time = models.FloatField(null=True)
|
||||
return_code = models.PositiveIntegerField(default=0)
|
||||
success = models.BooleanField(default=False)
|
||||
payload = models.TextField()
|
||||
response_body = models.TextField()
|
||||
|
||||
class Meta:
|
||||
ordering = ("-datetime",)
|
||||
45
src/pretix/api/oauth.py
Normal file
45
src/pretix/api/oauth.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from oauth2_provider.exceptions import FatalClientError
|
||||
from oauth2_provider.oauth2_validators import Grant, OAuth2Validator
|
||||
from oauth2_provider.settings import oauth2_settings
|
||||
|
||||
|
||||
class Validator(OAuth2Validator):
|
||||
|
||||
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
if not getattr(request, 'organizers', None):
|
||||
raise FatalClientError('No organizers selected.')
|
||||
|
||||
expires = timezone.now() + timedelta(
|
||||
seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS)
|
||||
g = Grant(application=request.client, user=request.user, code=code["code"],
|
||||
expires=expires, redirect_uri=request.redirect_uri,
|
||||
scope=" ".join(request.scopes))
|
||||
g.save()
|
||||
g.organizers.add(*request.organizers.all())
|
||||
|
||||
def validate_code(self, client_id, code, client, request, *args, **kwargs):
|
||||
try:
|
||||
grant = Grant.objects.get(code=code, application=client)
|
||||
if not grant.is_expired():
|
||||
request.scopes = grant.scope.split(" ")
|
||||
request.user = grant.user
|
||||
request.organizers = grant.organizers.all()
|
||||
return True
|
||||
return False
|
||||
|
||||
except Grant.DoesNotExist:
|
||||
return False
|
||||
|
||||
def _create_access_token(self, expires, request, token, source_refresh_token=None):
|
||||
if not getattr(request, 'organizers', None) and not getattr(source_refresh_token, 'access_token'):
|
||||
raise FatalClientError('No organizers selected.')
|
||||
if hasattr(request, 'organizers'):
|
||||
orgs = list(request.organizers.all())
|
||||
else:
|
||||
orgs = list(source_refresh_token.access_token.organizers.all())
|
||||
access_token = super()._create_access_token(expires, request, token, source_refresh_token=None)
|
||||
access_token.organizers.add(*orgs)
|
||||
return access_token
|
||||
131
src/pretix/api/serializers/cart.py
Normal file
131
src/pretix/api/serializers/cart.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
AnswerCreateSerializer, AnswerSerializer,
|
||||
)
|
||||
from pretix.base.models import Quota
|
||||
from pretix.base.models.orders import CartPosition
|
||||
|
||||
|
||||
class CartPositionSerializer(I18nAwareModelSerializer):
|
||||
answers = AnswerSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
|
||||
'answers',)
|
||||
|
||||
|
||||
class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
answers = AnswerCreateSerializer(many=True, required=False)
|
||||
expires = serializers.DateTimeField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'subevent', 'expires', 'includes_tax', 'answers',)
|
||||
|
||||
def create(self, validated_data):
|
||||
answers_data = validated_data.pop('answers')
|
||||
if not validated_data.get('cart_id'):
|
||||
cid = "{}@api".format(get_random_string(48))
|
||||
while CartPosition.objects.filter(cart_id=cid).exists():
|
||||
cid = "{}@api".format(get_random_string(48))
|
||||
validated_data['cart_id'] = cid
|
||||
|
||||
if not validated_data.get('expires'):
|
||||
validated_data['expires'] = now() + timedelta(
|
||||
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
|
||||
)
|
||||
|
||||
with self.context['event'].lock():
|
||||
new_quotas = (validated_data.get('variation').quotas.filter(subevent=validated_data.get('subevent'))
|
||||
if validated_data.get('variation')
|
||||
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
|
||||
if len(new_quotas) == 0:
|
||||
raise ValidationError(
|
||||
ugettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(validated_data.get('item'))
|
||||
)
|
||||
)
|
||||
for quota in new_quotas:
|
||||
avail = quota.availability()
|
||||
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
|
||||
raise ValidationError(
|
||||
ugettext_lazy('There is not enough quota available on quota "{}" to perform '
|
||||
'the operation.').format(
|
||||
quota.name
|
||||
)
|
||||
)
|
||||
attendee_name = validated_data.pop('attendee_name', '')
|
||||
if attendee_name and not validated_data.get('attendee_name_parts'):
|
||||
validated_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options')
|
||||
answ = cp.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
return cp
|
||||
|
||||
def validate_cart_id(self, cid):
|
||||
if cid and not cid.endswith('@api'):
|
||||
raise ValidationError('Cart ID should end in @api or be empty.')
|
||||
|
||||
def validate_item(self, item):
|
||||
if item.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
'The specified item does not belong to this event.'
|
||||
)
|
||||
if not item.active:
|
||||
raise ValidationError(
|
||||
'The specified item is not active.'
|
||||
)
|
||||
return item
|
||||
|
||||
def validate_subevent(self, subevent):
|
||||
if self.context['event'].has_subevents:
|
||||
if not subevent:
|
||||
raise ValidationError(
|
||||
'You need to set a subevent.'
|
||||
)
|
||||
if subevent.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
'The specified subevent does not belong to this event.'
|
||||
)
|
||||
elif subevent:
|
||||
raise ValidationError(
|
||||
'You cannot set a subevent for this event.'
|
||||
)
|
||||
return subevent
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('item'):
|
||||
if data.get('item').has_variations:
|
||||
if not data.get('variation'):
|
||||
raise ValidationError('You should specify a variation for this item.')
|
||||
else:
|
||||
if data.get('variation').item != data.get('item'):
|
||||
raise ValidationError(
|
||||
'The specified variation does not belong to the specified item.'
|
||||
)
|
||||
elif data.get('variation'):
|
||||
raise ValidationError(
|
||||
'You cannot specify a variation for this item.'
|
||||
)
|
||||
if data.get('attendee_name') and data.get('attendee_name_parts'):
|
||||
raise ValidationError(
|
||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||
)
|
||||
return data
|
||||
@@ -1,5 +1,11 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext as _
|
||||
from django_countries.serializers import CountryFieldMixin
|
||||
from rest_framework.fields import Field
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import Event, TaxRule
|
||||
@@ -14,15 +20,162 @@ class MetaDataField(Field):
|
||||
v.property.name: v.value for v in value.meta_values.all()
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return {
|
||||
'meta_data': data
|
||||
}
|
||||
|
||||
|
||||
class PluginsField(Field):
|
||||
|
||||
def to_representation(self, obj):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
return {
|
||||
p.module for p in get_all_plugins()
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return {
|
||||
'plugins': data
|
||||
}
|
||||
|
||||
|
||||
class EventSerializer(I18nAwareModelSerializer):
|
||||
meta_data = MetaDataField(source='*')
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
plugins = PluginsField(required=False, source='*')
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ('name', 'slug', 'live', 'currency', 'date_from',
|
||||
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
|
||||
'date_to', 'date_admission', 'is_public', 'presale_start',
|
||||
'presale_end', 'location', 'has_subevents', 'meta_data')
|
||||
'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
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'))
|
||||
|
||||
return data
|
||||
|
||||
def validate_has_subevents(self, value):
|
||||
Event.clean_has_subevents(self.instance, value)
|
||||
return value
|
||||
|
||||
def validate_slug(self, value):
|
||||
Event.clean_slug(self.context['request'].organizer, self.instance, value)
|
||||
return value
|
||||
|
||||
def validate_live(self, value):
|
||||
if value:
|
||||
if self.instance is None:
|
||||
raise ValidationError(_('Events cannot be created as \'live\'. Quotas and payment must be added to the '
|
||||
'event before sales can go live.'))
|
||||
else:
|
||||
self.instance.clean_live()
|
||||
return value
|
||||
|
||||
@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
|
||||
|
||||
def validate_plugins(self, value):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
plugins_available = {
|
||||
p.module for p in get_all_plugins(self.instance)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
|
||||
for plugin in value.get('plugins'):
|
||||
if plugin not in plugins_available:
|
||||
raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin))
|
||||
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
|
||||
event = super().create(validated_data)
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
for key, value in meta_data.items():
|
||||
event.meta_values.create(
|
||||
property=self.meta_properties.get(key),
|
||||
value=value
|
||||
)
|
||||
|
||||
# Plugins
|
||||
if plugins is not None:
|
||||
event.set_active_plugins(plugins)
|
||||
event.save(update_fields=['plugins'])
|
||||
|
||||
return event
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
plugins = validated_data.pop('plugins', None)
|
||||
event = super().update(instance, validated_data)
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
current = {mv.property: mv for mv in event.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:
|
||||
event.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()
|
||||
|
||||
# Plugins
|
||||
if plugins is not None:
|
||||
event.set_active_plugins(plugins)
|
||||
event.save()
|
||||
|
||||
return event
|
||||
|
||||
|
||||
class CloneEventSerializer(EventSerializer):
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
plugins = validated_data.pop('plugins', None)
|
||||
is_public = validated_data.pop('is_public', None)
|
||||
new_event = super().create(validated_data)
|
||||
|
||||
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
|
||||
new_event.copy_data_from(event)
|
||||
|
||||
if plugins is not None:
|
||||
new_event.set_active_plugins(plugins)
|
||||
if is_public is not None:
|
||||
new_event.is_public = is_public
|
||||
new_event.save()
|
||||
|
||||
return new_event
|
||||
|
||||
|
||||
class SubEventItemSerializer(I18nAwareModelSerializer):
|
||||
@@ -40,12 +193,13 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer):
|
||||
class SubEventSerializer(I18nAwareModelSerializer):
|
||||
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True)
|
||||
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True)
|
||||
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',
|
||||
'presale_start', 'presale_end', 'location', 'event',
|
||||
'item_price_overrides', 'variation_price_overrides', 'meta_data')
|
||||
|
||||
|
||||
|
||||
@@ -15,13 +15,20 @@ class I18nField(Field):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def to_representation(self, value):
|
||||
if value is None or value.data is None:
|
||||
if hasattr(value, 'data'):
|
||||
if isinstance(value.data, dict):
|
||||
return value.data
|
||||
elif value.data is None:
|
||||
return None
|
||||
else:
|
||||
return {
|
||||
settings.LANGUAGE_CODE: str(value.data)
|
||||
}
|
||||
elif value is None:
|
||||
return None
|
||||
if isinstance(value.data, dict):
|
||||
return value.data
|
||||
else:
|
||||
return {
|
||||
settings.LANGUAGE_CODE: str(value.data)
|
||||
settings.LANGUAGE_CODE: str(value)
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
|
||||
@@ -74,12 +74,12 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
fields = ('id', 'category', 'name', 'active', 'description',
|
||||
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')
|
||||
'variations', 'addons', 'original_price', 'require_approval', 'generate_tickets')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
|
||||
def get_serializer_context(self):
|
||||
@@ -129,7 +129,7 @@ class ItemCategorySerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ItemCategory
|
||||
fields = ('id', 'name', 'description', 'position', 'is_addon')
|
||||
fields = ('id', 'name', 'internal_name', 'description', 'position', 'is_addon')
|
||||
|
||||
|
||||
class QuestionOptionSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import json
|
||||
from collections import Counter
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy
|
||||
from django_countries.fields import Country
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
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,
|
||||
QuestionAnswer,
|
||||
Question, QuestionAnswer,
|
||||
)
|
||||
from pretix.base.models.orders import OrderFee
|
||||
from pretix.base.models.orders import (
|
||||
CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
)
|
||||
from pretix.base.pdf import get_variables
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
|
||||
|
||||
class CompatibleCountryField(serializers.Field):
|
||||
def to_internal_value(self, data):
|
||||
return {self.field_name: Country(data)}
|
||||
|
||||
def to_representation(self, instance: InvoiceAddress):
|
||||
if instance.country:
|
||||
return str(instance.country)
|
||||
@@ -20,13 +35,30 @@ class CompatibleCountryField(serializers.Field):
|
||||
return instance.country_old
|
||||
|
||||
|
||||
class InvoiceAdddressSerializer(I18nAwareModelSerializer):
|
||||
class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
country = CompatibleCountryField(source='*')
|
||||
name = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('last_modified', 'is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||
'vat_id_validated', 'internal_reference')
|
||||
fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country',
|
||||
'vat_id', 'vat_id_validated', 'internal_reference')
|
||||
read_only_fields = ('last_modified', 'vat_id_validated')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for v in self.fields.values():
|
||||
v.required = False
|
||||
v.allow_blank = True
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('name') and data.get('name_parts'):
|
||||
raise ValidationError(
|
||||
{'name': ['Do not specify name if you specified name_parts.']}
|
||||
)
|
||||
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
|
||||
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
return data
|
||||
|
||||
|
||||
class AnswerQuestionIdentifierField(serializers.Field):
|
||||
@@ -57,7 +89,8 @@ class CheckinSerializer(I18nAwareModelSerializer):
|
||||
class OrderDownloadsField(serializers.Field):
|
||||
def to_representation(self, instance: Order):
|
||||
if instance.status != Order.STATUS_PAID:
|
||||
return []
|
||||
if instance.status != Order.STATUS_PENDING or instance.require_approval or not instance.event.settings.ticket_download_pending:
|
||||
return []
|
||||
|
||||
request = self.context['request']
|
||||
res = []
|
||||
@@ -80,10 +113,9 @@ class OrderDownloadsField(serializers.Field):
|
||||
class PositionDownloadsField(serializers.Field):
|
||||
def to_representation(self, instance: OrderPosition):
|
||||
if instance.order.status != Order.STATUS_PAID:
|
||||
return []
|
||||
if instance.addon_to_id and not instance.order.event.settings.ticket_download_addons:
|
||||
return []
|
||||
if not instance.item.admission and not instance.order.event.settings.ticket_download_nonadm:
|
||||
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending:
|
||||
return []
|
||||
if not instance.generate_ticket:
|
||||
return []
|
||||
|
||||
request = self.context['request']
|
||||
@@ -104,17 +136,67 @@ class PositionDownloadsField(serializers.Field):
|
||||
return res
|
||||
|
||||
|
||||
class PdfDataSerializer(serializers.Field):
|
||||
def to_representation(self, instance: OrderPosition):
|
||||
res = {}
|
||||
|
||||
ev = instance.subevent or instance.order.event
|
||||
with language(instance.order.locale):
|
||||
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
|
||||
# we serialize a list.
|
||||
|
||||
if 'vars' not in self.context:
|
||||
self.context['vars'] = get_variables(self.context['request'].event)
|
||||
|
||||
for k, f in self.context['vars'].items():
|
||||
res[k] = f['evaluate'](instance, instance.order, ev)
|
||||
|
||||
if not hasattr(ev, '_cached_meta_data'):
|
||||
ev._cached_meta_data = ev.meta_data
|
||||
|
||||
for k, v in ev._cached_meta_data.items():
|
||||
res['meta:' + k] = v
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
checkins = CheckinSerializer(many=True)
|
||||
answers = AnswerSerializer(many=True)
|
||||
downloads = PositionDownloadsField(source='*')
|
||||
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
|
||||
pdf_data = PdfDataSerializer(source='*')
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
|
||||
'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'downloads',
|
||||
'answers', 'tax_rule')
|
||||
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')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if 'request' in self.context and not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
|
||||
self.fields.pop('pdf_data')
|
||||
|
||||
|
||||
class OrderPaymentTypeField(serializers.Field):
|
||||
# TODO: Remove after pretix 2.2
|
||||
def to_representation(self, instance: Order):
|
||||
t = None
|
||||
for p in instance.payments.all():
|
||||
t = p.provider
|
||||
return t
|
||||
|
||||
|
||||
class OrderPaymentDateField(serializers.DateField):
|
||||
# TODO: Remove after pretix 2.2
|
||||
def to_representation(self, instance: Order):
|
||||
t = None
|
||||
for p in instance.payments.all():
|
||||
t = p.payment_date or t
|
||||
if t:
|
||||
|
||||
return super().to_representation(t.date())
|
||||
|
||||
|
||||
class OrderFeeSerializer(I18nAwareModelSerializer):
|
||||
@@ -123,32 +205,412 @@ class OrderFeeSerializer(I18nAwareModelSerializer):
|
||||
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule')
|
||||
|
||||
|
||||
class PaymentFeeLegacyField(serializers.Field):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.attr = kwargs.pop('attribute')
|
||||
super().__init__(*args, **kwargs)
|
||||
class OrderPaymentSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = OrderPayment
|
||||
fields = ('local_id', 'state', 'amount', 'created', 'payment_date', 'provider')
|
||||
|
||||
def to_representation(self, instance: Order):
|
||||
return str(
|
||||
sum([getattr(f, self.attr) for f in instance.fees.all() if f.fee_type == OrderFee.FEE_TYPE_PAYMENT],
|
||||
Decimal('0.00'))
|
||||
)
|
||||
|
||||
class OrderRefundSerializer(I18nAwareModelSerializer):
|
||||
payment = SlugRelatedField(slug_field='local_id', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = OrderRefund
|
||||
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider')
|
||||
|
||||
|
||||
class OrderSerializer(I18nAwareModelSerializer):
|
||||
invoice_address = InvoiceAdddressSerializer()
|
||||
invoice_address = InvoiceAddressSerializer()
|
||||
positions = OrderPositionSerializer(many=True)
|
||||
fees = OrderFeeSerializer(many=True)
|
||||
downloads = OrderDownloadsField(source='*')
|
||||
payment_fee = PaymentFeeLegacyField(source='*', attribute='value') # TODO: Remove in 1.9
|
||||
payment_fee_tax_rate = PaymentFeeLegacyField(source='*', attribute='tax_rate') # TODO: Remove in 1.9
|
||||
payment_fee_tax_value = PaymentFeeLegacyField(source='*', attribute='tax_value') # TODO: Remove in 1.9
|
||||
payments = OrderPaymentSerializer(many=True)
|
||||
refunds = OrderRefundSerializer(many=True)
|
||||
payment_date = OrderPaymentDateField(source='*')
|
||||
payment_provider = OrderPaymentTypeField(source='*')
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
fields = ('code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
||||
'payment_fee', 'payment_fee_tax_rate', 'payment_fee_tax_value', 'checkin_attention')
|
||||
'checkin_attention', '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')
|
||||
|
||||
|
||||
class AnswerCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = QuestionAnswer
|
||||
fields = ('question', 'answer', 'options')
|
||||
|
||||
def validate_question(self, q):
|
||||
if q.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
'The specified question does not belong to this event.'
|
||||
)
|
||||
return q
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('question').type == Question.TYPE_FILE:
|
||||
raise ValidationError(
|
||||
'File uploads are currently not supported via the API.'
|
||||
)
|
||||
elif data.get('question').type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
if not data.get('options'):
|
||||
raise ValidationError(
|
||||
'You need to specify options if the question is of a choice type.'
|
||||
)
|
||||
if data.get('question').type == Question.TYPE_CHOICE and len(data.get('options')) > 1:
|
||||
raise ValidationError(
|
||||
'You can specify at most one option for this question.'
|
||||
)
|
||||
data['answer'] = ", ".join([str(o) for o in data.get('options')])
|
||||
|
||||
else:
|
||||
if data.get('options'):
|
||||
raise ValidationError(
|
||||
'You should not specify options if the question is not of a choice type.'
|
||||
)
|
||||
|
||||
if data.get('question').type == Question.TYPE_BOOLEAN:
|
||||
if data.get('answer') in ['true', 'True', '1', 'TRUE']:
|
||||
data['answer'] = 'True'
|
||||
elif data.get('answer') in ['false', 'False', '0', 'FALSE']:
|
||||
data['answer'] = 'False'
|
||||
else:
|
||||
raise ValidationError(
|
||||
'Please specify "true" or "false" for boolean questions.'
|
||||
)
|
||||
elif data.get('question').type == Question.TYPE_NUMBER:
|
||||
serializers.DecimalField(
|
||||
max_digits=50,
|
||||
decimal_places=25
|
||||
).to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_DATE:
|
||||
data['answer'] = serializers.DateField().to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_TIME:
|
||||
data['answer'] = serializers.TimeField().to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_DATETIME:
|
||||
data['answer'] = serializers.DateTimeField().to_internal_value(data.get('answer'))
|
||||
return data
|
||||
|
||||
|
||||
class OrderFeeCreateSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = OrderFee
|
||||
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rule')
|
||||
|
||||
def validate_tax_rule(self, tr):
|
||||
if tr and tr.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
'The specified tax rate does not belong to this event.'
|
||||
)
|
||||
return tr
|
||||
|
||||
|
||||
class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
answers = AnswerCreateSerializer(many=True, required=False)
|
||||
addon_to = serializers.IntegerField(required=False, allow_null=True)
|
||||
secret = serializers.CharField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'secret', 'addon_to', 'subevent', 'answers')
|
||||
|
||||
def validate_secret(self, secret):
|
||||
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
|
||||
raise ValidationError(
|
||||
'You cannot assign a position secret that already exists.'
|
||||
)
|
||||
return secret
|
||||
|
||||
def validate_item(self, item):
|
||||
if item.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
'The specified item does not belong to this event.'
|
||||
)
|
||||
if not item.active:
|
||||
raise ValidationError(
|
||||
'The specified item is not active.'
|
||||
)
|
||||
return item
|
||||
|
||||
def validate_subevent(self, subevent):
|
||||
if self.context['event'].has_subevents:
|
||||
if not subevent:
|
||||
raise ValidationError(
|
||||
'You need to set a subevent.'
|
||||
)
|
||||
if subevent.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
'The specified subevent does not belong to this event.'
|
||||
)
|
||||
elif subevent:
|
||||
raise ValidationError(
|
||||
'You cannot set a subevent for this event.'
|
||||
)
|
||||
return subevent
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('item'):
|
||||
if data.get('item').has_variations:
|
||||
if not data.get('variation'):
|
||||
raise ValidationError({'variation': ['You should specify a variation for this item.']})
|
||||
else:
|
||||
if data.get('variation').item != data.get('item'):
|
||||
raise ValidationError(
|
||||
{'variation': ['The specified variation does not belong to the specified item.']}
|
||||
)
|
||||
elif data.get('variation'):
|
||||
raise ValidationError(
|
||||
{'variation': ['You cannot specify a variation for this item.']}
|
||||
)
|
||||
if data.get('attendee_name') and data.get('attendee_name_parts'):
|
||||
raise ValidationError(
|
||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||
)
|
||||
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
|
||||
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
return data
|
||||
|
||||
|
||||
class CompatibleJSONField(serializers.JSONField):
|
||||
def to_internal_value(self, data):
|
||||
try:
|
||||
return json.dumps(data)
|
||||
except (TypeError, ValueError):
|
||||
self.fail('invalid')
|
||||
|
||||
def to_representation(self, value):
|
||||
if value:
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
|
||||
class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
invoice_address = InvoiceAddressSerializer(required=False)
|
||||
positions = OrderPositionCreateSerializer(many=True, required=False)
|
||||
fees = OrderFeeCreateSerializer(many=True, required=False)
|
||||
status = serializers.ChoiceField(choices=(
|
||||
('n', Order.STATUS_PENDING),
|
||||
('p', Order.STATUS_PAID),
|
||||
), default='n', required=False)
|
||||
code = serializers.CharField(
|
||||
required=False,
|
||||
max_length=16,
|
||||
min_length=5
|
||||
)
|
||||
comment = serializers.CharField(required=False, allow_blank=True)
|
||||
payment_provider = serializers.CharField(required=True)
|
||||
payment_info = CompatibleJSONField(required=False)
|
||||
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
|
||||
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')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
if pp not in self.context['event'].get_payment_providers():
|
||||
raise ValidationError('The given payment provider is not known.')
|
||||
return pp
|
||||
|
||||
def validate_sales_channel(self, channel):
|
||||
if channel not in get_all_sales_channels():
|
||||
raise ValidationError('Unknown sales channel.')
|
||||
return channel
|
||||
|
||||
def validate_code(self, code):
|
||||
if code and Order.objects.filter(event__organizer=self.context['event'].organizer, code=code).exists():
|
||||
raise ValidationError(
|
||||
'This order code is already in use.'
|
||||
)
|
||||
if any(c not in 'ABCDEFGHJKLMNPQRSTUVWXYZ1234567890' for c in code):
|
||||
raise ValidationError(
|
||||
'This order code contains invalid characters.'
|
||||
)
|
||||
return code
|
||||
|
||||
def validate_positions(self, data):
|
||||
if not data:
|
||||
raise ValidationError(
|
||||
'An order cannot be empty.'
|
||||
)
|
||||
errs = [{} for p in data]
|
||||
if any([p.get('positionid') for p in data]):
|
||||
if not all([p.get('positionid') for p in data]):
|
||||
for i, p in enumerate(data):
|
||||
if not p.get('positionid'):
|
||||
errs[i]['positionid'] = [
|
||||
'If you set position IDs manually, you need to do so for all positions.'
|
||||
]
|
||||
raise ValidationError(errs)
|
||||
|
||||
last_non_add_on = None
|
||||
last_posid = 0
|
||||
|
||||
for i, p in enumerate(data):
|
||||
if p['positionid'] != last_posid + 1:
|
||||
errs[i]['positionid'] = [
|
||||
'Position IDs need to be consecutive.'
|
||||
]
|
||||
if p.get('addon_to') and p['addon_to'] != last_non_add_on:
|
||||
errs[i]['addon_to'] = [
|
||||
"If you set addon_to, you need to make sure that the referenced "
|
||||
"position ID exists and is transmitted directly before its add-ons."
|
||||
]
|
||||
|
||||
if not p.get('addon_to'):
|
||||
last_non_add_on = p['positionid']
|
||||
last_posid = p['positionid']
|
||||
|
||||
elif any([p.get('addon_to') for p in data]):
|
||||
errs = [
|
||||
{'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]}
|
||||
for p in data
|
||||
]
|
||||
|
||||
if any(errs):
|
||||
raise ValidationError(errs)
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
fees_data = validated_data.pop('fees') if 'fees' in validated_data else []
|
||||
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', '{}')
|
||||
|
||||
if 'invoice_address' in validated_data:
|
||||
iadata = validated_data.pop('invoice_address')
|
||||
name = iadata.pop('name', '')
|
||||
if name and not iadata.get('name_parts'):
|
||||
iadata['name_parts'] = {
|
||||
'_legacy': name
|
||||
}
|
||||
ia = InvoiceAddress(**iadata)
|
||||
else:
|
||||
ia = None
|
||||
|
||||
with self.context['event'].lock() as now_dt:
|
||||
quotadiff = Counter()
|
||||
|
||||
consume_carts = validated_data.pop('consume_carts', [])
|
||||
delete_cps = []
|
||||
quota_avail_cache = {}
|
||||
if consume_carts:
|
||||
for cp in CartPosition.objects.filter(event=self.context['event'], cart_id__in=consume_carts):
|
||||
quotas = (cp.variation.quotas.filter(subevent=cp.subevent)
|
||||
if cp.variation else cp.item.quotas.filter(subevent=cp.subevent))
|
||||
for quota in quotas:
|
||||
if quota not in quota_avail_cache:
|
||||
quota_avail_cache[quota] = list(quota.availability())
|
||||
if quota_avail_cache[quota][1] is not None:
|
||||
quota_avail_cache[quota][1] += 1
|
||||
if cp.expires > now_dt:
|
||||
quotadiff.subtract(quotas)
|
||||
delete_cps.append(cp)
|
||||
|
||||
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 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)
|
||||
|
||||
if any(errs):
|
||||
raise ValidationError({'positions': errs})
|
||||
|
||||
if validated_data.get('locale', None) is None:
|
||||
validated_data['locale'] = self.context['event'].settings.locale
|
||||
order = Order(event=self.context['event'], **validated_data)
|
||||
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
|
||||
order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00'))
|
||||
order.meta_info = "{}"
|
||||
order.save()
|
||||
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
order.payments.create(
|
||||
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
payment_date=now()
|
||||
)
|
||||
elif payment_provider == "free" and order.total != Decimal('0.00'):
|
||||
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
|
||||
elif validated_data.get('status') == Order.STATUS_PAID:
|
||||
order.payments.create(
|
||||
amount=order.total,
|
||||
provider=payment_provider,
|
||||
info=payment_info,
|
||||
payment_date=now(),
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
)
|
||||
elif payment_provider:
|
||||
order.payments.create(
|
||||
amount=order.total,
|
||||
provider=payment_provider,
|
||||
info=payment_info,
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED
|
||||
)
|
||||
|
||||
if ia:
|
||||
ia.order = order
|
||||
ia.save()
|
||||
pos_map = {}
|
||||
for pos_data in positions_data:
|
||||
answers_data = pos_data.pop('answers', [])
|
||||
addon_to = pos_data.pop('addon_to', None)
|
||||
attendee_name = pos_data.pop('attendee_name', '')
|
||||
if attendee_name and not pos_data.get('attendee_name_parts'):
|
||||
pos_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
pos = OrderPosition(**pos_data)
|
||||
pos.order = order
|
||||
pos._calculate_tax()
|
||||
if addon_to:
|
||||
pos.addon_to = pos_map[addon_to]
|
||||
pos.save()
|
||||
pos_map[pos.positionid] = pos
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options', [])
|
||||
answ = pos.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
|
||||
for cp in delete_cps:
|
||||
cp.delete()
|
||||
for fee_data in fees_data:
|
||||
f = OrderFee(**fee_data)
|
||||
f.order = order
|
||||
f._calculate_tax()
|
||||
f.save()
|
||||
|
||||
return order
|
||||
|
||||
|
||||
class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
||||
@@ -168,3 +630,27 @@ class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines',
|
||||
'foreign_currency_display', 'foreign_currency_rate', 'foreign_currency_rate_date',
|
||||
'internal_reference')
|
||||
|
||||
|
||||
class OrderRefundCreateSerializer(I18nAwareModelSerializer):
|
||||
payment = serializers.IntegerField(required=False, allow_null=True)
|
||||
provider = serializers.CharField(required=True, allow_null=False, allow_blank=False)
|
||||
info = CompatibleJSONField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = OrderRefund
|
||||
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info')
|
||||
|
||||
def create(self, validated_data):
|
||||
pid = validated_data.pop('payment', None)
|
||||
if pid:
|
||||
try:
|
||||
p = self.context['order'].payments.get(local_id=pid)
|
||||
except OrderPayment.DoesNotExist:
|
||||
raise ValidationError('Unknown payment ID.')
|
||||
else:
|
||||
p = None
|
||||
|
||||
order = OrderRefund(order=self.context['order'], payment=p, **validated_data)
|
||||
order.save()
|
||||
return order
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import Voucher
|
||||
|
||||
|
||||
class VoucherListSerializer(serializers.ListSerializer):
|
||||
def create(self, validated_data):
|
||||
codes = set()
|
||||
errs = []
|
||||
err = False
|
||||
for voucher_data in validated_data:
|
||||
if voucher_data['code'] in codes:
|
||||
err = True
|
||||
errs.append({'code': ['Duplicate voucher code in request.']})
|
||||
else:
|
||||
codes.add(voucher_data['code'])
|
||||
errs.append({})
|
||||
if err:
|
||||
raise ValidationError(errs)
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class VoucherSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Voucher
|
||||
@@ -9,6 +29,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
|
||||
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
|
||||
'tag', 'comment', 'subevent')
|
||||
read_only_fields = ('id', 'redeemed')
|
||||
list_serializer_class = VoucherListSerializer
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import WaitingListEntry
|
||||
|
||||
@@ -6,4 +8,28 @@ class WaitingListSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = WaitingListEntry
|
||||
fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale', 'subevent')
|
||||
fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale', 'subevent', 'priority')
|
||||
read_only_fields = ('id', 'created', 'voucher')
|
||||
|
||||
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)
|
||||
|
||||
WaitingListEntry.clean_duplicate(full_data.get('email'), full_data.get('item'), full_data.get('variation'),
|
||||
full_data.get('subevent'), self.instance.pk if self.instance else None)
|
||||
WaitingListEntry.clean_itemvar(event, full_data.get('item'), full_data.get('variation'))
|
||||
WaitingListEntry.clean_subevent(event, full_data.get('subevent'))
|
||||
|
||||
if 'item' in data or 'variation' in data:
|
||||
availability = (
|
||||
full_data.get('variation').check_quotas(count_waitinglist=True, subevent=full_data.get('subevent'))
|
||||
if full_data.get('variation')
|
||||
else full_data.get('item').check_quotas(count_waitinglist=True, subevent=full_data.get('subevent'))
|
||||
)
|
||||
if availability[0] == 100:
|
||||
raise ValidationError("This product is currently available.")
|
||||
|
||||
return data
|
||||
|
||||
71
src/pretix/api/serializers/webhooks.py
Normal file
71
src/pretix/api/serializers/webhooks.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.models import WebHook
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.webhooks import get_all_webhook_events
|
||||
from pretix.base.models import Event
|
||||
|
||||
|
||||
class EventRelatedField(serializers.SlugRelatedField):
|
||||
def get_queryset(self):
|
||||
return self.context['organizer'].events.all()
|
||||
|
||||
|
||||
class ActionTypesField(serializers.Field):
|
||||
def to_representation(self, instance: WebHook):
|
||||
return instance.action_types
|
||||
|
||||
def to_internal_value(self, data):
|
||||
types = get_all_webhook_events()
|
||||
for d in data:
|
||||
if d not in types:
|
||||
raise ValidationError('Invalid action type "%s".' % d)
|
||||
return {'action_types': data}
|
||||
|
||||
|
||||
class WebHookSerializer(I18nAwareModelSerializer):
|
||||
limit_events = EventRelatedField(
|
||||
slug_field='slug',
|
||||
queryset=Event.objects.none(),
|
||||
many=True
|
||||
)
|
||||
action_types = ActionTypesField(source='*')
|
||||
|
||||
class Meta:
|
||||
model = WebHook
|
||||
fields = ('id', 'enabled', 'target_url', 'all_events', 'limit_events', 'action_types')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
for event in full_data.get('limit_events'):
|
||||
if self.context['organizer'] != event.organizer:
|
||||
raise ValidationError('One or more events do not belong to this organizer.')
|
||||
|
||||
if full_data.get('limit_events') and full_data.get('all_events'):
|
||||
raise ValidationError('You can set either limit_events or all_events.')
|
||||
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
action_types = validated_data.pop('action_types')
|
||||
inst = super().create(validated_data)
|
||||
for l in action_types:
|
||||
inst.listeners.create(action_type=l)
|
||||
return inst
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
action_types = validated_data.pop('action_types', None)
|
||||
instance = super().update(instance, validated_data)
|
||||
if action_types is not None:
|
||||
current_listeners = set(instance.listeners.values_list('action_type', flat=True))
|
||||
new_listeners = set(action_types)
|
||||
for l in current_listeners - new_listeners:
|
||||
instance.listeners.filter(action_type=l).delete()
|
||||
for l in new_listeners - current_listeners:
|
||||
instance.listeners.create(action_type=l)
|
||||
return instance
|
||||
21
src/pretix/api/signals.py
Normal file
21
src/pretix/api/signals.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.dispatch import Signal, receiver
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix.api.models import WebHookCall
|
||||
from pretix.base.signals import periodic_task
|
||||
|
||||
register_webhook_events = Signal(
|
||||
providing_args=[]
|
||||
)
|
||||
"""
|
||||
This signal is sent out to get all known webhook events. Receivers should return an
|
||||
instance of a subclass of pretix.api.webhooks.WebhookEvent or a list of such
|
||||
instances.
|
||||
"""
|
||||
|
||||
|
||||
@receiver(periodic_task)
|
||||
def cleanup_webhook_logs(sender, **kwargs):
|
||||
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()
|
||||
@@ -4,16 +4,24 @@ from django.apps import apps
|
||||
from django.conf.urls import include, url
|
||||
from rest_framework import routers
|
||||
|
||||
from .views import checkin, event, item, order, organizer, voucher, waitinglist
|
||||
from pretix.api.views import cart
|
||||
|
||||
from .views import (
|
||||
checkin, device, event, item, oauth, order, organizer, user, voucher,
|
||||
waitinglist, webhooks,
|
||||
)
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register(r'organizers', organizer.OrganizerViewSet)
|
||||
|
||||
orga_router = routers.DefaultRouter()
|
||||
orga_router.register(r'events', event.EventViewSet)
|
||||
orga_router.register(r'subevents', event.SubEventViewSet)
|
||||
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
|
||||
|
||||
event_router = routers.DefaultRouter()
|
||||
event_router.register(r'subevents', event.SubEventViewSet)
|
||||
event_router.register(r'clone', event.CloneEventViewSet)
|
||||
event_router.register(r'items', item.ItemViewSet)
|
||||
event_router.register(r'categories', item.ItemCategoryViewSet)
|
||||
event_router.register(r'questions', item.QuestionViewSet)
|
||||
@@ -25,6 +33,7 @@ event_router.register(r'invoices', order.InvoiceViewSet)
|
||||
event_router.register(r'taxrules', event.TaxRuleViewSet)
|
||||
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
||||
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
||||
event_router.register(r'cartpositions', cart.CartPositionViewSet)
|
||||
|
||||
checkinlist_router = routers.DefaultRouter()
|
||||
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet)
|
||||
@@ -36,6 +45,10 @@ item_router = routers.DefaultRouter()
|
||||
item_router.register(r'variations', item.ItemVariationViewSet)
|
||||
item_router.register(r'addons', item.ItemAddOnViewSet)
|
||||
|
||||
order_router = routers.DefaultRouter()
|
||||
order_router.register(r'payments', order.PaymentViewSet)
|
||||
order_router.register(r'refunds', order.RefundViewSet)
|
||||
|
||||
# Force import of all plugins to give them a chance to register URLs with the router
|
||||
for app in apps.get_app_configs():
|
||||
if hasattr(app, 'PretixPluginMeta'):
|
||||
@@ -51,4 +64,13 @@ urlpatterns = [
|
||||
include(question_router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/checkinlists/(?P<list>[^/]+)/',
|
||||
include(checkinlist_router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/orders/(?P<order>[^/]+)/', include(order_router.urls)),
|
||||
url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"),
|
||||
url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"),
|
||||
url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"),
|
||||
url(r"^device/initialize$", device.InitializeView.as_view(), name="device.initialize"),
|
||||
url(r"^device/update$", device.UpdateView.as_view(), name="device.update"),
|
||||
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
|
||||
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
|
||||
url(r"^me$", user.MeView.as_view(), name="user.me"),
|
||||
]
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
from calendar import timegm
|
||||
|
||||
from django.db.models import Max
|
||||
from django.http import HttpResponse
|
||||
from django.utils.http import http_date, parse_http_date_safe
|
||||
from rest_framework.filters import OrderingFilter
|
||||
|
||||
|
||||
@@ -21,3 +26,36 @@ class RichOrderingFilter(OrderingFilter):
|
||||
return queryset.order_by(*ordering)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class ConditionalListView:
|
||||
|
||||
def list(self, request, **kwargs):
|
||||
if_modified_since = request.META.get('HTTP_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 if_unmodified_since:
|
||||
if_unmodified_since = parse_http_date_safe(if_unmodified_since)
|
||||
if not hasattr(request, 'event'):
|
||||
return super().list(request, **kwargs)
|
||||
|
||||
lmd = request.event.logentry_set.filter(
|
||||
content_type__model=self.queryset.model._meta.model_name,
|
||||
content_type__app_label=self.queryset.model._meta.app_label,
|
||||
).aggregate(
|
||||
m=Max('datetime')
|
||||
)['m']
|
||||
if lmd:
|
||||
lmd_ts = timegm(lmd.utctimetuple())
|
||||
|
||||
if if_unmodified_since and lmd and lmd_ts > if_unmodified_since:
|
||||
return HttpResponse(status=412)
|
||||
|
||||
if if_modified_since and lmd and lmd_ts <= if_modified_since:
|
||||
return HttpResponse(status=304)
|
||||
|
||||
resp = super().list(request, **kwargs)
|
||||
if lmd:
|
||||
resp['Last-Modified'] = http_date(lmd_ts)
|
||||
return resp
|
||||
|
||||
46
src/pretix/api/views/cart.py
Normal file
46
src/pretix/api/views/cart.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from django.db import transaction
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.cart import (
|
||||
CartPositionCreateSerializer, CartPositionSerializer,
|
||||
)
|
||||
from pretix.base.models import CartPosition
|
||||
|
||||
|
||||
class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = CartPositionSerializer
|
||||
queryset = CartPosition.objects.none()
|
||||
filter_backends = (OrderingFilter,)
|
||||
ordering = ('datetime',)
|
||||
ordering_fields = ('datetime', 'cart_id')
|
||||
lookup_field = 'id'
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
return CartPosition.objects.filter(
|
||||
event=self.request.event,
|
||||
cart_id__endswith="@api"
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
self.perform_create(serializer)
|
||||
cp = serializer.instance
|
||||
serializer = CartPositionSerializer(cp, context=serializer.context)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
@@ -1,16 +1,27 @@
|
||||
import django_filters
|
||||
from django.db.models import F, Max, OuterRef, Prefetch, Q, Subquery
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count, F, Max, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import detail_route
|
||||
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.views import RichOrderingFilter
|
||||
from pretix.base.models import Checkin, CheckinList, Order, OrderPosition
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Event, Order, OrderPosition,
|
||||
)
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, perform_checkin,
|
||||
)
|
||||
from pretix.helpers.database import FixedOrderBy
|
||||
|
||||
|
||||
@@ -24,7 +35,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = CheckinListSerializer
|
||||
queryset = CheckinList.objects.none()
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filter_class = CheckinListFilter
|
||||
filterset_class = CheckinListFilter
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_event_settings'
|
||||
|
||||
@@ -40,7 +51,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.checkinlist.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -54,7 +65,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.checkinlist.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -62,32 +73,90 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
instance.log_action(
|
||||
'pretix.event.checkinlist.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@detail_route(methods=['GET'])
|
||||
def status(self, *args, **kwargs):
|
||||
clist = self.get_object()
|
||||
cqs = Checkin.objects.filter(
|
||||
position__order__event=clist.event,
|
||||
position__order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []),
|
||||
list=clist
|
||||
)
|
||||
pqs = OrderPosition.objects.filter(
|
||||
order__event=clist.event,
|
||||
order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []),
|
||||
subevent=clist.subevent,
|
||||
)
|
||||
if not clist.all_products:
|
||||
pqs = pqs.filter(item__in=clist.limit_products.values_list('id', flat=True))
|
||||
|
||||
class OrderPositionFilter(FilterSet):
|
||||
order = django_filters.CharFilter(name='order', lookup_expr='code')
|
||||
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
|
||||
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
|
||||
ev = clist.subevent or clist.event
|
||||
response = {
|
||||
'event': {
|
||||
'name': str(ev.name),
|
||||
},
|
||||
'checkin_count': cqs.count(),
|
||||
'position_count': pqs.count()
|
||||
}
|
||||
|
||||
op_by_item = {
|
||||
p['item']: p['cnt']
|
||||
for p in pqs.order_by().values('item').annotate(cnt=Count('id'))
|
||||
}
|
||||
op_by_variation = {
|
||||
p['variation']: p['cnt']
|
||||
for p in pqs.order_by().values('variation').annotate(cnt=Count('id'))
|
||||
}
|
||||
c_by_item = {
|
||||
p['position__item']: p['cnt']
|
||||
for p in cqs.order_by().values('position__item').annotate(cnt=Count('id'))
|
||||
}
|
||||
c_by_variation = {
|
||||
p['position__variation']: p['cnt']
|
||||
for p in cqs.order_by().values('position__variation').annotate(cnt=Count('id'))
|
||||
}
|
||||
|
||||
if not clist.all_products:
|
||||
items = clist.limit_products
|
||||
else:
|
||||
items = clist.event.items
|
||||
|
||||
response['items'] = []
|
||||
for item in items.order_by('category__position', 'position', 'pk').prefetch_related('variations'):
|
||||
i = {
|
||||
'id': item.pk,
|
||||
'name': str(item),
|
||||
'admission': item.admission,
|
||||
'checkin_count': c_by_item.get(item.pk, 0),
|
||||
'position_count': op_by_item.get(item.pk, 0),
|
||||
'variations': []
|
||||
}
|
||||
for var in item.variations.all():
|
||||
i['variations'].append({
|
||||
'id': var.pk,
|
||||
'value': str(var),
|
||||
'checkin_count': c_by_variation.get(var.pk, 0),
|
||||
'position_count': op_by_variation.get(var.pk, 0),
|
||||
})
|
||||
response['items'].append(i)
|
||||
|
||||
return Response(response)
|
||||
|
||||
|
||||
class CheckinOrderPositionFilter(OrderPositionFilter):
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
return queryset.filter(last_checked_in__isnull=not value)
|
||||
|
||||
def attendee_name_qs(self, queryset, name, value):
|
||||
return queryset.filter(Q(attendee_name=value) | Q(addon_to__attendee_name=value))
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ['item', 'variation', 'attendee_name', 'secret', 'order', 'has_checkin', 'addon_to', 'subevent']
|
||||
|
||||
|
||||
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPositionSerializer
|
||||
queryset = OrderPosition.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
|
||||
ordering = ('attendee_name', 'positionid')
|
||||
ordering = ('attendee_name_cached', 'positionid')
|
||||
ordering_fields = (
|
||||
'order__code', 'order__datetime', 'positionid', 'attendee_name',
|
||||
'last_checked_in', 'order__email',
|
||||
@@ -95,11 +164,11 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ordering_custom = {
|
||||
'attendee_name': {
|
||||
'_order': F('display_name').asc(nulls_first=True),
|
||||
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
'-attendee_name': {
|
||||
'_order': F('display_name').desc(nulls_last=True),
|
||||
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
'last_checked_in': {
|
||||
'_order': FixedOrderBy(F('last_checked_in'), nulls_first=True),
|
||||
@@ -109,12 +178,16 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
},
|
||||
}
|
||||
|
||||
filter_class = OrderPositionFilter
|
||||
filterset_class = CheckinOrderPositionFilter
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
@cached_property
|
||||
def checkinlist(self):
|
||||
return get_object_or_404(CheckinList, event=self.request.event, pk=self.kwargs.get("list"))
|
||||
try:
|
||||
return get_object_or_404(CheckinList, event=self.request.event, pk=self.kwargs.get("list"))
|
||||
except ValueError:
|
||||
raise Http404()
|
||||
|
||||
def get_queryset(self):
|
||||
cqs = Checkin.objects.filter(
|
||||
@@ -130,14 +203,107 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
subevent=self.checkinlist.subevent
|
||||
).annotate(
|
||||
last_checked_in=Subquery(cqs)
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
),
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
Event.objects.select_related('organizer')
|
||||
),
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to'
|
||||
)
|
||||
).select_related('item', 'variation', 'order', 'addon_to')
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address')
|
||||
|
||||
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'])
|
||||
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()
|
||||
|
||||
if 'datetime' in self.request.data:
|
||||
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
|
||||
else:
|
||||
dt = now()
|
||||
|
||||
given_answers = {}
|
||||
if 'answers' in self.request.data:
|
||||
aws = self.request.data.get('answers')
|
||||
for q in op.item.questions.filter(ask_during_checkin=True):
|
||||
if str(q.pk) in aws:
|
||||
try:
|
||||
given_answers[q] = q.clean_answer(aws[str(q.pk)])
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
try:
|
||||
perform_checkin(
|
||||
op=op,
|
||||
clist=self.checkinlist,
|
||||
given_answers=given_answers,
|
||||
force=force,
|
||||
ignore_unpaid=ignore_unpaid,
|
||||
nonce=nonce,
|
||||
datetime=dt,
|
||||
questions_supported=self.request.data.get('questions_supported', True),
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
'status': 'incomplete',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data,
|
||||
'questions': [
|
||||
QuestionSerializer(q).data for q in e.questions
|
||||
]
|
||||
}, status=400)
|
||||
except CheckInError as e:
|
||||
return Response({
|
||||
'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
|
||||
}, 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
|
||||
}, status=201)
|
||||
|
||||
def get_object(self):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
if self.kwargs['pk'].isnumeric():
|
||||
obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
||||
else:
|
||||
obj = get_object_or_404(queryset, secret=self.kwargs['pk'])
|
||||
return obj
|
||||
|
||||
113
src/pretix/api/views/device.py
Normal file
113
src/pretix/api/views/device.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
|
||||
from django.utils.timezone import now
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from pretix.api.auth.device import DeviceTokenAuthentication
|
||||
from pretix.base.models import Device
|
||||
from pretix.base.models.devices import generate_api_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InitializationRequestSerializer(serializers.Serializer):
|
||||
token = serializers.CharField(max_length=190)
|
||||
hardware_brand = serializers.CharField(max_length=190)
|
||||
hardware_model = serializers.CharField(max_length=190)
|
||||
software_brand = serializers.CharField(max_length=190)
|
||||
software_version = serializers.CharField(max_length=190)
|
||||
|
||||
|
||||
class UpdateRequestSerializer(serializers.Serializer):
|
||||
hardware_brand = serializers.CharField(max_length=190)
|
||||
hardware_model = serializers.CharField(max_length=190)
|
||||
software_brand = serializers.CharField(max_length=190)
|
||||
software_version = serializers.CharField(max_length=190)
|
||||
|
||||
|
||||
class DeviceSerializer(serializers.ModelSerializer):
|
||||
organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
'organizer', 'device_id', 'unique_serial', 'api_token',
|
||||
'name'
|
||||
]
|
||||
|
||||
|
||||
class InitializeView(APIView):
|
||||
authentication_classes = tuple()
|
||||
permission_classes = tuple()
|
||||
|
||||
def post(self, request, format=None):
|
||||
serializer = InitializationRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
device = Device.objects.get(initialization_token=serializer.validated_data.get('token'))
|
||||
except Device.DoesNotExist:
|
||||
raise ValidationError({'token': ['Unknown initialization token.']})
|
||||
|
||||
if device.initialized:
|
||||
raise ValidationError({'token': ['This initialization token has already been used.']})
|
||||
|
||||
device.initialized = now()
|
||||
device.hardware_brand = serializer.validated_data.get('hardware_brand')
|
||||
device.hardware_model = serializer.validated_data.get('hardware_model')
|
||||
device.software_brand = serializer.validated_data.get('software_brand')
|
||||
device.software_version = serializer.validated_data.get('software_version')
|
||||
device.api_token = generate_api_token()
|
||||
device.save()
|
||||
|
||||
device.log_action('pretix.device.initialized', data=serializer.validated_data, auth=device)
|
||||
|
||||
serializer = DeviceSerializer(device)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class UpdateView(APIView):
|
||||
authentication_classes = (DeviceTokenAuthentication,)
|
||||
|
||||
def post(self, request, format=None):
|
||||
serializer = UpdateRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
device = request.auth
|
||||
device.hardware_brand = serializer.validated_data.get('hardware_brand')
|
||||
device.hardware_model = serializer.validated_data.get('hardware_model')
|
||||
device.software_brand = serializer.validated_data.get('software_brand')
|
||||
device.software_version = serializer.validated_data.get('software_version')
|
||||
device.save()
|
||||
device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device)
|
||||
|
||||
serializer = DeviceSerializer(device)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class RollKeyView(APIView):
|
||||
authentication_classes = (DeviceTokenAuthentication,)
|
||||
|
||||
def post(self, request, format=None):
|
||||
device = request.auth
|
||||
device.api_token = generate_api_token()
|
||||
device.save()
|
||||
device.log_action('pretix.device.keyroll', auth=device)
|
||||
|
||||
serializer = DeviceSerializer(device)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class RevokeKeyView(APIView):
|
||||
authentication_classes = (DeviceTokenAuthentication,)
|
||||
|
||||
def post(self, request, format=None):
|
||||
device = request.auth
|
||||
device.api_token = None
|
||||
device.save()
|
||||
device.log_action('pretix.device.revoked', auth=device)
|
||||
|
||||
serializer = DeviceSerializer(device)
|
||||
return Response(serializer.data)
|
||||
@@ -1,44 +1,247 @@
|
||||
import django_filters
|
||||
from django.db import transaction
|
||||
from django.db.models import ProtectedError, Q
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import filters, viewsets
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from pretix.api.auth.permission import EventCRUDPermission
|
||||
from pretix.api.serializers.event import (
|
||||
EventSerializer, SubEventSerializer, TaxRuleSerializer,
|
||||
CloneEventSerializer, EventSerializer, SubEventSerializer,
|
||||
TaxRuleSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
Device, Event, ItemCategory, TaxRule, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models import Event, ItemCategory, TaxRule
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
|
||||
class EventViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class EventFilter(FilterSet):
|
||||
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
|
||||
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
|
||||
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ['is_public', 'live', 'has_subevents']
|
||||
|
||||
def ends_after_qs(self, queryset, name, value):
|
||||
expr = (
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
|
||||
)
|
||||
)
|
||||
return queryset.filter(expr)
|
||||
|
||||
def is_past_qs(self, queryset, name, value):
|
||||
expr = (
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
|
||||
)
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
def is_future_qs(self, queryset, name, value):
|
||||
expr = (
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
|
||||
)
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
|
||||
class EventViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = EventSerializer
|
||||
queryset = Event.objects.none()
|
||||
lookup_field = 'slug'
|
||||
lookup_url_kwarg = 'event'
|
||||
permission_classes = (EventCRUDPermission,)
|
||||
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
|
||||
filterset_class = EventFilter
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.events.prefetch_related('meta_values', 'meta_values__property')
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
qs = self.request.auth.get_events_with_any_permission()
|
||||
elif self.request.user.is_authenticated:
|
||||
qs = self.request.user.get_events_with_any_permission(self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
|
||||
return qs.prefetch_related(
|
||||
'meta_values', 'meta_values__property'
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
current_live_value = serializer.instance.live
|
||||
updated_live_value = serializer.validated_data.get('live', None)
|
||||
current_plugins_value = serializer.instance.get_plugins()
|
||||
updated_plugins_value = serializer.validated_data.get('plugins', None)
|
||||
|
||||
super().perform_update(serializer)
|
||||
|
||||
if updated_live_value is not None and updated_live_value != current_live_value:
|
||||
log_action = 'pretix.event.live.activated' if updated_live_value else 'pretix.event.live.deactivated'
|
||||
serializer.instance.log_action(
|
||||
log_action,
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
if updated_plugins_value is not None and set(updated_plugins_value) != set(current_plugins_value):
|
||||
enabled = {m: 'enabled' for m in updated_plugins_value if m not in current_plugins_value}
|
||||
disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value}
|
||||
changed = merge_dicts(enabled, disabled)
|
||||
|
||||
for module, action in changed.items():
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.plugins.' + action,
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'plugin': module}
|
||||
)
|
||||
|
||||
other_keys = {k: v for k, v in serializer.validated_data.items() if k not in ['plugins', 'live']}
|
||||
if other_keys:
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(organizer=self.request.organizer)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.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 event can not be deleted as it already contains orders. Please set \'live\''
|
||||
' to false to hide the event and take the shop offline instead.')
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance.organizer.log_action(
|
||||
'pretix.event.deleted', user=self.request.user,
|
||||
data={
|
||||
'event_id': instance.pk,
|
||||
'name': str(instance.name),
|
||||
'logentries': list(instance.logentry_set.values_list('pk', flat=True))
|
||||
}
|
||||
)
|
||||
instance.delete_sub_objects()
|
||||
super().perform_destroy(instance)
|
||||
except ProtectedError:
|
||||
raise PermissionDenied('The event could not be deleted as some constraints (e.g. data created by plug-ins) '
|
||||
'do not allow it.')
|
||||
|
||||
|
||||
class CloneEventViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = CloneEventSerializer
|
||||
queryset = Event.objects.none()
|
||||
lookup_field = 'slug'
|
||||
lookup_url_kwarg = 'event'
|
||||
http_method_names = ['post']
|
||||
write_permission = 'can_create_events'
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.kwargs['event']
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(organizer=self.request.organizer)
|
||||
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
|
||||
class SubEventFilter(FilterSet):
|
||||
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
|
||||
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
|
||||
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
fields = ['active']
|
||||
fields = ['active', 'event__live']
|
||||
|
||||
def ends_after_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
|
||||
)
|
||||
return queryset.filter(expr)
|
||||
|
||||
def is_past_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
def is_future_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
|
||||
class SubEventViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class SubEventViewSet(ConditionalListView, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = SubEventSerializer
|
||||
queryset = ItemCategory.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
|
||||
filter_class = SubEventFilter
|
||||
filterset_class = SubEventFilter
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.subevents.prefetch_related(
|
||||
if getattr(self.request, 'event', None):
|
||||
qs = self.request.event.subevents
|
||||
elif isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
qs = SubEvent.objects.filter(
|
||||
event__organizer=self.request.organizer,
|
||||
event__in=self.request.auth.get_events_with_any_permission()
|
||||
)
|
||||
elif self.request.user.is_authenticated:
|
||||
qs = SubEvent.objects.filter(
|
||||
event__organizer=self.request.organizer,
|
||||
event__in=self.request.user.get_events_with_any_permission()
|
||||
)
|
||||
return qs.prefetch_related(
|
||||
'subeventitem_set', 'subeventitemvariation_set'
|
||||
)
|
||||
|
||||
|
||||
class TaxRuleViewSet(viewsets.ModelViewSet):
|
||||
class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = TaxRuleSerializer
|
||||
queryset = TaxRule.objects.none()
|
||||
write_permission = 'can_change_event_settings'
|
||||
@@ -51,7 +254,7 @@ class TaxRuleViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.taxrule.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -60,7 +263,7 @@ class TaxRuleViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.taxrule.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -71,6 +274,6 @@ class TaxRuleViewSet(viewsets.ModelViewSet):
|
||||
instance.log_action(
|
||||
'pretix.event.taxrule.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@@ -13,11 +13,11 @@ from pretix.api.serializers.item import (
|
||||
ItemVariationSerializer, QuestionOptionSerializer, QuestionSerializer,
|
||||
QuotaSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
|
||||
Quota,
|
||||
)
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
|
||||
@@ -35,14 +35,14 @@ class ItemFilter(FilterSet):
|
||||
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
|
||||
|
||||
|
||||
class ItemViewSet(viewsets.ModelViewSet):
|
||||
class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = ItemSerializer
|
||||
queryset = Item.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
filter_class = ItemFilter
|
||||
permission = 'can_change_items'
|
||||
filterset_class = ItemFilter
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -53,7 +53,7 @@ class ItemViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.item.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -68,7 +68,7 @@ class ItemViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.item.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -81,8 +81,9 @@ class ItemViewSet(viewsets.ModelViewSet):
|
||||
instance.log_action(
|
||||
'pretix.event.item.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
)
|
||||
self.get_object().cartposition_set.all().delete()
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
@@ -92,7 +93,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('id',)
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -113,7 +114,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
item.log_action(
|
||||
'pretix.event.item.variation.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk},
|
||||
{'value': serializer.instance.value})
|
||||
)
|
||||
@@ -123,7 +124,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.item.log_action(
|
||||
'pretix.event.item.variation.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk},
|
||||
{'value': serializer.instance.value})
|
||||
)
|
||||
@@ -140,7 +141,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
instance.item.log_action(
|
||||
'pretix.event.item.variation.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'value': instance.value,
|
||||
'id': self.kwargs['pk']
|
||||
@@ -154,7 +155,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('id',)
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -174,7 +175,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
item.log_action(
|
||||
'pretix.event.item.addons.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
@@ -183,7 +184,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.base_item.log_action(
|
||||
'pretix.event.item.addons.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
@@ -192,7 +193,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
instance.base_item.log_action(
|
||||
'pretix.event.item.addons.removed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data={'category': instance.addon_category.pk}
|
||||
)
|
||||
|
||||
@@ -203,14 +204,14 @@ class ItemCategoryFilter(FilterSet):
|
||||
fields = ['is_addon']
|
||||
|
||||
|
||||
class ItemCategoryViewSet(viewsets.ModelViewSet):
|
||||
class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = ItemCategorySerializer
|
||||
queryset = ItemCategory.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
filter_class = ItemCategoryFilter
|
||||
filterset_class = ItemCategoryFilter
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -221,7 +222,7 @@ class ItemCategoryViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.category.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -235,7 +236,7 @@ class ItemCategoryViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.category.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -246,18 +247,26 @@ class ItemCategoryViewSet(viewsets.ModelViewSet):
|
||||
instance.log_action(
|
||||
'pretix.event.category.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class QuestionViewSet(viewsets.ModelViewSet):
|
||||
class QuestionFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ['ask_during_checkin', 'required', 'identifier']
|
||||
|
||||
|
||||
class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = QuestionSerializer
|
||||
queryset = Question.objects.none()
|
||||
filter_backends = (OrderingFilter,)
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
filterset_class = QuestionFilter
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.questions.prefetch_related('options').all()
|
||||
@@ -267,7 +276,7 @@ class QuestionViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.question.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -281,7 +290,7 @@ class QuestionViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.question.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -289,7 +298,7 @@ class QuestionViewSet(viewsets.ModelViewSet):
|
||||
instance.log_action(
|
||||
'pretix.event.question.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@@ -300,7 +309,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position',)
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -319,7 +328,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
q.log_action(
|
||||
'pretix.event.question.option.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
@@ -328,7 +337,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.question.log_action(
|
||||
'pretix.event.question.option.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
@@ -336,7 +345,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
instance.question.log_action(
|
||||
'pretix.event.question.option.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data={'id': instance.pk}
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
@@ -348,14 +357,14 @@ class QuotaFilter(FilterSet):
|
||||
fields = ['subevent']
|
||||
|
||||
|
||||
class QuotaViewSet(viewsets.ModelViewSet):
|
||||
class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = QuotaSerializer
|
||||
queryset = Quota.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
filter_class = QuotaFilter
|
||||
filterset_class = QuotaFilter
|
||||
ordering_fields = ('id', 'size')
|
||||
ordering = ('id',)
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -366,14 +375,14 @@ class QuotaViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.quota.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
if serializer.instance.subevent:
|
||||
serializer.instance.subevent.log_action(
|
||||
'pretix.subevent.quota.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -389,7 +398,7 @@ class QuotaViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.quota.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
if current_subevent == request_subevent:
|
||||
@@ -397,7 +406,7 @@ class QuotaViewSet(viewsets.ModelViewSet):
|
||||
current_subevent.log_action(
|
||||
'pretix.subevent.quota.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
else:
|
||||
@@ -405,14 +414,14 @@ class QuotaViewSet(viewsets.ModelViewSet):
|
||||
request_subevent.log_action(
|
||||
'pretix.subevent.quota.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
if current_subevent is not None:
|
||||
current_subevent.log_action(
|
||||
'pretix.subevent.quota.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
)
|
||||
serializer.instance.rebuild_cache()
|
||||
|
||||
@@ -420,13 +429,13 @@ class QuotaViewSet(viewsets.ModelViewSet):
|
||||
instance.log_action(
|
||||
'pretix.event.quota.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
)
|
||||
if instance.subevent:
|
||||
instance.subevent.log_action(
|
||||
'pretix.subevent.quota.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
92
src/pretix/api/views/oauth.py
Normal file
92
src/pretix/api/views/oauth.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
from oauth2_provider.exceptions import OAuthToolkitError
|
||||
from oauth2_provider.forms import AllowForm
|
||||
from oauth2_provider.views import (
|
||||
AuthorizationView as BaseAuthorizationView,
|
||||
RevokeTokenView as BaseRevokeTokenView, TokenView as BaseTokenView,
|
||||
)
|
||||
|
||||
from pretix.api.models import OAuthApplication
|
||||
from pretix.base.models import Organizer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OAuthAllowForm(AllowForm):
|
||||
organizers = forms.ModelMultipleChoiceField(
|
||||
queryset=Organizer.objects.none(),
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['organizers'].queryset = Organizer.objects.filter(
|
||||
pk__in=user.teams.values_list('organizer', flat=True))
|
||||
|
||||
|
||||
class AuthorizationView(BaseAuthorizationView):
|
||||
template_name = "pretixcontrol/auth/oauth_authorization.html"
|
||||
form_class = OAuthAllowForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['user'] = self.request.user
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['settings'] = settings
|
||||
return ctx
|
||||
|
||||
def create_authorization_response(self, request, scopes, credentials, allow, organizers):
|
||||
credentials["organizers"] = organizers
|
||||
return super().create_authorization_response(request, scopes, credentials, allow)
|
||||
|
||||
def form_valid(self, form):
|
||||
client_id = form.cleaned_data["client_id"]
|
||||
application = OAuthApplication.objects.get(client_id=client_id)
|
||||
credentials = {
|
||||
"client_id": form.cleaned_data.get("client_id"),
|
||||
"redirect_uri": form.cleaned_data.get("redirect_uri"),
|
||||
"response_type": form.cleaned_data.get("response_type", None),
|
||||
"state": form.cleaned_data.get("state", None),
|
||||
}
|
||||
scopes = form.cleaned_data.get("scope")
|
||||
allow = form.cleaned_data.get("allow")
|
||||
|
||||
try:
|
||||
uri, headers, body, status = self.create_authorization_response(
|
||||
request=self.request, scopes=scopes, credentials=credentials, allow=allow,
|
||||
organizers=form.cleaned_data.get("organizers")
|
||||
)
|
||||
except OAuthToolkitError as error:
|
||||
return self.error_response(error, application)
|
||||
|
||||
self.success_url = uri
|
||||
logger.debug("Success url for the request: {0}".format(self.success_url))
|
||||
|
||||
msgs = [
|
||||
_('The application "{application_name}" has been authorized to access your account.').format(
|
||||
application_name=application.name
|
||||
)
|
||||
]
|
||||
self.request.user.send_security_notice(msgs)
|
||||
self.request.user.log_action('pretix.user.oauth.authorized', user=self.request.user, data={
|
||||
'application_id': application.pk,
|
||||
'application_name': application.name,
|
||||
})
|
||||
|
||||
return self.redirect(self.success_url, application)
|
||||
|
||||
|
||||
class TokenView(BaseTokenView):
|
||||
pass
|
||||
|
||||
|
||||
class RevokeTokenView(BaseRevokeTokenView):
|
||||
pass
|
||||
@@ -1,60 +1,105 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import django_filters
|
||||
import pytz
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Concat
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Prefetch, Q
|
||||
from django.db.models.functions import Coalesce, Concat
|
||||
from django.http import FileResponse
|
||||
from django.utils.timezone import make_aware
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import serializers, status, viewsets
|
||||
from rest_framework import mixins, serializers, status, viewsets
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.exceptions import APIException, NotFound, PermissionDenied
|
||||
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.response import Response
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.serializers.order import (
|
||||
InvoiceSerializer, OrderPositionSerializer, OrderSerializer,
|
||||
InvoiceSerializer, OrderCreateSerializer, OrderPaymentSerializer,
|
||||
OrderPositionSerializer, OrderRefundCreateSerializer,
|
||||
OrderRefundSerializer, OrderSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, Order,
|
||||
OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
||||
regenerate_invoice,
|
||||
)
|
||||
from pretix.base.models import Invoice, Order, OrderPosition, Quota
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.services.invoices import invoice_pdf
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.orders import (
|
||||
OrderError, cancel_order, extend_order, mark_order_expired,
|
||||
mark_order_paid,
|
||||
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
|
||||
extend_order, mark_order_expired, mark_order_refunded,
|
||||
)
|
||||
from pretix.base.services.tickets import (
|
||||
get_cachedticket_for_order, get_cachedticket_for_position,
|
||||
)
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.base.services.tickets import generate
|
||||
from pretix.base.signals import order_placed, register_ticket_outputs
|
||||
|
||||
|
||||
class OrderFilter(FilterSet):
|
||||
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
|
||||
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')
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['code', 'status', 'email', 'locale']
|
||||
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
|
||||
|
||||
|
||||
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderSerializer
|
||||
queryset = Order.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering = ('datetime',)
|
||||
ordering_fields = ('datetime', 'code', 'status')
|
||||
filter_class = OrderFilter
|
||||
ordering_fields = ('datetime', 'code', 'status', 'last_modified')
|
||||
filterset_class = OrderFilter
|
||||
lookup_field = 'code'
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.orders.prefetch_related(
|
||||
'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options',
|
||||
'positions__answers__question', 'fees'
|
||||
qs = self.request.event.orders.prefetch_related(
|
||||
'fees', 'payments', 'refunds', 'refunds__payment'
|
||||
).select_related(
|
||||
'invoice_address'
|
||||
)
|
||||
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'item__category', 'addon_to',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return qs
|
||||
|
||||
def _get_output_provider(self, identifier):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
@@ -63,6 +108,20 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
return prov
|
||||
raise NotFound('Unknown output provider.')
|
||||
|
||||
def list(self, request, **kwargs):
|
||||
date = serializers.DateTimeField().to_representation(now())
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
resp = self.get_paginated_response(serializer.data)
|
||||
resp['X-Page-Generated'] = date
|
||||
return resp
|
||||
|
||||
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>[^/]+)')
|
||||
def download(self, request, output, **kwargs):
|
||||
provider = self._get_output_provider(output)
|
||||
@@ -71,9 +130,11 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
if order.status != Order.STATUS_PAID:
|
||||
raise PermissionDenied("Downloads are not available for unpaid orders.")
|
||||
|
||||
ct = get_cachedticket_for_order(order, provider.identifier)
|
||||
|
||||
if not ct.file:
|
||||
ct = CachedCombinedTicket.objects.filter(
|
||||
order=order, provider=provider.identifier, file__isnull=False
|
||||
).last()
|
||||
if not ct or not ct.file:
|
||||
generate.apply_async(args=('order', order.pk, provider.identifier))
|
||||
raise RetryException()
|
||||
else:
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
@@ -88,14 +149,33 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
order = self.get_object()
|
||||
|
||||
if order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED):
|
||||
|
||||
ps = order.pending_sum
|
||||
try:
|
||||
mark_order_paid(
|
||||
order, manual=True,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
|
||||
p = order.payments.get(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
|
||||
provider='manual',
|
||||
amount=ps
|
||||
)
|
||||
except OrderPayment.DoesNotExist:
|
||||
order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING,
|
||||
OrderPayment.PAYMENT_STATE_CREATED)) \
|
||||
.update(state=OrderPayment.PAYMENT_STATE_CANCELED)
|
||||
p = order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
provider='manual',
|
||||
amount=ps,
|
||||
fee=None
|
||||
)
|
||||
|
||||
try:
|
||||
p.confirm(auth=self.request.auth,
|
||||
user=self.request.user if request.user.is_authenticated else None,
|
||||
count_waitinglist=False)
|
||||
except Quota.QuotaExceededException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except PaymentException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except SendMailException:
|
||||
pass
|
||||
|
||||
@@ -108,6 +188,12 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@detail_route(methods=['POST'])
|
||||
def mark_canceled(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
cancellation_fee = request.data.get('cancellation_fee', None)
|
||||
if cancellation_fee:
|
||||
try:
|
||||
cancellation_fee = float(Decimal(cancellation_fee))
|
||||
except:
|
||||
cancellation_fee = None
|
||||
|
||||
order = self.get_object()
|
||||
if not order.cancel_allowed():
|
||||
@@ -116,12 +202,57 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
cancel_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
|
||||
send_mail=send_mail
|
||||
)
|
||||
try:
|
||||
cancel_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
|
||||
device=request.auth if isinstance(request.auth, Device) else None,
|
||||
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
||||
send_mail=send_mail,
|
||||
cancellation_fee=cancellation_fee
|
||||
)
|
||||
except OrderError as e:
|
||||
return Response(
|
||||
{'detail': str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def approve(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
|
||||
order = self.get_object()
|
||||
try:
|
||||
approve_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
|
||||
send_mail=send_mail,
|
||||
)
|
||||
except Quota.QuotaExceededException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except OrderError as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def deny(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
comment = request.data.get('comment', '')
|
||||
|
||||
order = self.get_object()
|
||||
try:
|
||||
deny_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
|
||||
send_mail=send_mail,
|
||||
comment=comment,
|
||||
)
|
||||
except OrderError as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@@ -135,12 +266,11 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
|
||||
order.status = Order.STATUS_PENDING
|
||||
order.payment_manual = True
|
||||
order.save()
|
||||
order.save(update_fields=['status'])
|
||||
order.log_action(
|
||||
'pretix.event.order.unpaid',
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
|
||||
auth=request.auth,
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@@ -157,11 +287,26 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
mark_order_expired(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
|
||||
auth=request.auth,
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
# TODO: Find a way to implement mark_refunded
|
||||
@detail_route(methods=['POST'])
|
||||
def mark_refunded(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
|
||||
if order.status != Order.STATUS_PAID:
|
||||
return Response(
|
||||
{'detail': 'The order is not paid.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
mark_order_refunded(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=(request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken, Device)) else None),
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def extend(self, request, **kwargs):
|
||||
@@ -196,7 +341,7 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
new_date=new_date,
|
||||
force=force,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
|
||||
auth=request.auth,
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
except OrderError as e:
|
||||
@@ -205,39 +350,127 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = OrderCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
self.perform_create(serializer)
|
||||
order = serializer.instance
|
||||
serializer = OrderSerializer(order, context=serializer.context)
|
||||
|
||||
order.log_action(
|
||||
'pretix.event.order.placed',
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth,
|
||||
)
|
||||
order_placed.send(self.request.event, order=order)
|
||||
|
||||
gen_invoice = invoice_qualified(order) and (
|
||||
(order.event.settings.get('invoice_generate') == 'True') or
|
||||
(order.event.settings.get('invoice_generate') == 'paid' and order.status == Order.STATUS_PAID)
|
||||
) and not order.invoices.last()
|
||||
if gen_invoice:
|
||||
generate_invoice(order, trigger_pdf=True)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.testmode:
|
||||
raise PermissionDenied('Only test mode orders can be deleted.')
|
||||
|
||||
with transaction.atomic():
|
||||
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
|
||||
|
||||
class OrderPositionFilter(FilterSet):
|
||||
order = django_filters.CharFilter(name='order', lookup_expr='code')
|
||||
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
|
||||
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
|
||||
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
|
||||
search = django_filters.CharFilter(method='search_qs')
|
||||
|
||||
def search_qs(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
Q(secret__istartswith=value)
|
||||
| Q(attendee_name_cached__icontains=value)
|
||||
| Q(addon_to__attendee_name_cached__icontains=value)
|
||||
| Q(order__code__istartswith=value)
|
||||
| Q(order__invoice_address__name_cached__icontains=value)
|
||||
| Q(order__email__icontains=value)
|
||||
)
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
return queryset.filter(checkins__isnull=not value)
|
||||
|
||||
def attendee_name_qs(self, queryset, name, value):
|
||||
return queryset.filter(Q(attendee_name=value) | Q(addon_to__attendee_name=value))
|
||||
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ['item', 'variation', 'attendee_name', 'secret', 'order', 'order__status', 'has_checkin',
|
||||
'addon_to', 'subevent']
|
||||
fields = {
|
||||
'item': ['exact', 'in'],
|
||||
'variation': ['exact', 'in'],
|
||||
'secret': ['exact'],
|
||||
'order__status': ['exact', 'in'],
|
||||
'addon_to': ['exact', 'in'],
|
||||
'subevent': ['exact', 'in'],
|
||||
'pseudonymization_id': ['exact'],
|
||||
'voucher__code': ['exact'],
|
||||
'voucher': ['exact'],
|
||||
}
|
||||
|
||||
|
||||
class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPositionSerializer
|
||||
queryset = OrderPosition.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering = ('order__datetime', 'positionid')
|
||||
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
|
||||
filter_class = OrderPositionFilter
|
||||
filterset_class = OrderPositionFilter
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
ordering_custom = {
|
||||
'attendee_name': {
|
||||
'_order': F('display_name').asc(nulls_first=True),
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
'-attendee_name': {
|
||||
'_order': F('display_name').asc(nulls_last=True),
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question'
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer'
|
||||
)
|
||||
qs = OrderPosition.objects.filter(order__event=self.request.event)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
Event.objects.select_related('organizer')
|
||||
),
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to'
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question'
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer'
|
||||
)
|
||||
return qs
|
||||
|
||||
def _get_output_provider(self, identifier):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
@@ -254,14 +487,14 @@ class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
if pos.order.status != Order.STATUS_PAID:
|
||||
raise PermissionDenied("Downloads are not available for unpaid orders.")
|
||||
if pos.addon_to_id and not request.event.settings.ticket_download_addons:
|
||||
raise PermissionDenied("Downloads are not enabled for add-on products.")
|
||||
if not pos.item.admission and not request.event.settings.ticket_download_nonadm:
|
||||
raise PermissionDenied("Downloads are not enabled for non-admission products.")
|
||||
if not pos.generate_ticket:
|
||||
raise PermissionDenied("Downloads are not enabled for this product.")
|
||||
|
||||
ct = get_cachedticket_for_position(pos, provider.identifier)
|
||||
|
||||
if not ct.file:
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=pos, provider=provider.identifier, file__isnull=False
|
||||
).last()
|
||||
if not ct or not ct.file:
|
||||
generate.apply_async(args=('orderposition', pos.pk, provider.identifier))
|
||||
raise RetryException()
|
||||
else:
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
@@ -271,11 +504,242 @@ class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
return resp
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
try:
|
||||
ocm = OrderChangeManager(
|
||||
instance.order,
|
||||
user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth,
|
||||
notify=False
|
||||
)
|
||||
ocm.cancel(instance)
|
||||
ocm.commit()
|
||||
except OrderError as e:
|
||||
raise ValidationError(str(e))
|
||||
except Quota.QuotaExceededException as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
|
||||
class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPaymentSerializer
|
||||
queryset = OrderPayment.objects.none()
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
lookup_field = 'local_id'
|
||||
|
||||
def get_queryset(self):
|
||||
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
||||
return order.payments.all()
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def confirm(self, request, **kwargs):
|
||||
payment = self.get_object()
|
||||
force = request.data.get('force', False)
|
||||
|
||||
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
|
||||
return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
payment.confirm(user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth,
|
||||
count_waitinglist=False,
|
||||
force=force)
|
||||
except Quota.QuotaExceededException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except PaymentException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except SendMailException:
|
||||
pass
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def refund(self, request, **kwargs):
|
||||
payment = self.get_object()
|
||||
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
||||
request.data.get('amount', str(payment.amount))
|
||||
)
|
||||
if 'mark_refunded' in request.data:
|
||||
mark_refunded = request.data.get('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.get('mark_canceled', False)
|
||||
|
||||
if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED:
|
||||
return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
full_refund_possible = payment.payment_provider.payment_refund_supported(payment)
|
||||
partial_refund_possible = payment.payment_provider.payment_partial_refund_supported(payment)
|
||||
available_amount = payment.amount - payment.refunded_amount
|
||||
|
||||
if amount <= 0:
|
||||
return Response({'amount': ['Invalid refund amount.']}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if amount > available_amount:
|
||||
return Response(
|
||||
{'amount': ['Invalid refund amount, only {} are available to refund.'.format(available_amount)]},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
if amount != payment.amount and not partial_refund_possible:
|
||||
return Response({'amount': ['Partial refund not available for this payment method.']},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
if amount == payment.amount and not full_refund_possible:
|
||||
return Response({'amount': ['Full refund not available for this payment method.']},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
r = payment.order.refunds.create(
|
||||
payment=payment,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=amount,
|
||||
provider=payment.provider
|
||||
)
|
||||
|
||||
try:
|
||||
r.payment_provider.execute_refund(r)
|
||||
except PaymentException as e:
|
||||
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||
r.save()
|
||||
return Response({'detail': 'External error: {}'.format(str(e))},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
payment.order.log_action('pretix.event.order.refund.created', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
if payment.order.pending_sum > 0:
|
||||
if mark_refunded:
|
||||
mark_order_refunded(payment.order,
|
||||
user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth)
|
||||
else:
|
||||
payment.order.status = Order.STATUS_PENDING
|
||||
payment.order.set_expires(
|
||||
now(),
|
||||
payment.order.event.subevents.filter(
|
||||
id__in=payment.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
payment.order.save(update_fields=['status', 'expires'])
|
||||
return Response(OrderRefundSerializer(r).data, status=status.HTTP_200_OK)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def cancel(self, request, **kwargs):
|
||||
payment = self.get_object()
|
||||
|
||||
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
|
||||
return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
with transaction.atomic():
|
||||
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
|
||||
payment.save()
|
||||
payment.order.log_action('pretix.event.order.payment.canceled', {
|
||||
'local_id': payment.local_id,
|
||||
'provider': payment.provider,
|
||||
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
|
||||
class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderRefundSerializer
|
||||
queryset = OrderRefund.objects.none()
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
lookup_field = 'local_id'
|
||||
|
||||
def get_queryset(self):
|
||||
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
||||
return order.refunds.all()
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def cancel(self, request, **kwargs):
|
||||
refund = self.get_object()
|
||||
|
||||
if refund.state not in (OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT,
|
||||
OrderRefund.REFUND_STATE_EXTERNAL):
|
||||
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
with transaction.atomic():
|
||||
refund.state = OrderRefund.REFUND_STATE_CANCELED
|
||||
refund.save()
|
||||
refund.order.log_action('pretix.event.order.refund.canceled', {
|
||||
'local_id': refund.local_id,
|
||||
'provider': refund.provider,
|
||||
}, 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'])
|
||||
def process(self, request, **kwargs):
|
||||
refund = self.get_object()
|
||||
|
||||
if refund.state != OrderRefund.REFUND_STATE_EXTERNAL:
|
||||
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
if 'mark_refunded' in request.data:
|
||||
mark_refunded = request.data.get('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.get('mark_canceled', False)
|
||||
if mark_refunded:
|
||||
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth)
|
||||
elif not (refund.order.status == Order.STATUS_PAID and refund.order.pending_sum <= 0):
|
||||
refund.order.status = Order.STATUS_PENDING
|
||||
refund.order.set_expires(
|
||||
now(),
|
||||
refund.order.event.subevents.filter(
|
||||
id__in=refund.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
refund.order.save(update_fields=['status', 'expires'])
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def done(self, request, **kwargs):
|
||||
refund = self.get_object()
|
||||
|
||||
if refund.state not in (OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT):
|
||||
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['order'] = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
||||
return ctx
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
if 'mark_refunded' in request.data:
|
||||
mark_refunded = request.data.pop('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.pop('mark_canceled', False)
|
||||
serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
self.perform_create(serializer)
|
||||
r = serializer.instance
|
||||
serializer = OrderRefundSerializer(r, context=serializer.context)
|
||||
|
||||
r.order.log_action(
|
||||
'pretix.event.order.refund.created', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
},
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth
|
||||
)
|
||||
if mark_refunded:
|
||||
mark_order_refunded(
|
||||
r.order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=(request.auth if request.auth else None),
|
||||
)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
|
||||
class InvoiceFilter(FilterSet):
|
||||
refers = django_filters.CharFilter(method='refers_qs')
|
||||
number = django_filters.CharFilter(method='nr_qs')
|
||||
order = django_filters.CharFilter(name='order', lookup_expr='code__iexact')
|
||||
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
|
||||
|
||||
def refers_qs(self, queryset, name, value):
|
||||
return queryset.annotate(
|
||||
@@ -302,10 +766,11 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering = ('nr',)
|
||||
ordering_fields = ('nr', 'date')
|
||||
filter_class = InvoiceFilter
|
||||
filterset_class = InvoiceFilter
|
||||
permission = 'can_view_orders'
|
||||
lookup_url_kwarg = 'number'
|
||||
lookup_field = 'nr'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.invoices.prefetch_related('lines').select_related('order', 'refers').annotate(
|
||||
@@ -320,9 +785,54 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
invoice_pdf(invoice.pk)
|
||||
invoice.refresh_from_db()
|
||||
|
||||
if invoice.shredded:
|
||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||
|
||||
if not invoice.file:
|
||||
raise RetryException()
|
||||
|
||||
resp = FileResponse(invoice.file.file, content_type='application/pdf')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
|
||||
return resp
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def regenerate(self, request, **kwarts):
|
||||
inv = self.get_object()
|
||||
if inv.canceled:
|
||||
raise ValidationError('The invoice has already been canceled.')
|
||||
elif inv.shredded:
|
||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||
else:
|
||||
inv = regenerate_invoice(inv)
|
||||
inv.order.log_action(
|
||||
'pretix.event.order.invoice.regenerated',
|
||||
data={
|
||||
'invoice': inv.pk
|
||||
},
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
return Response(status=204)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def reissue(self, request, **kwarts):
|
||||
inv = self.get_object()
|
||||
if inv.canceled:
|
||||
raise ValidationError('The invoice has already been canceled.')
|
||||
elif inv.shredded:
|
||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||
else:
|
||||
c = generate_cancellation(inv)
|
||||
if inv.order.status != Order.STATUS_CANCELED:
|
||||
inv = generate_invoice(inv.order)
|
||||
else:
|
||||
inv = c
|
||||
inv.order.log_action(
|
||||
'pretix.event.order.invoice.reissued',
|
||||
data={
|
||||
'invoice': inv.pk
|
||||
},
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
return Response(status=204)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from rest_framework import viewsets
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.serializers.organizer import OrganizerSerializer
|
||||
from pretix.base.models import Organizer
|
||||
|
||||
@@ -11,10 +12,18 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
lookup_url_kwarg = 'organizer'
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_authenticated():
|
||||
if self.request.user.is_authenticated:
|
||||
if self.request.user.has_active_staff_session(self.request.session.session_key):
|
||||
return Organizer.objects.all()
|
||||
elif isinstance(self.request.auth, OAuthAccessToken):
|
||||
return Organizer.objects.filter(
|
||||
pk__in=self.request.user.teams.values_list('organizer', flat=True)
|
||||
).filter(
|
||||
pk__in=self.request.auth.organizers.values_list('pk', flat=True)
|
||||
)
|
||||
else:
|
||||
return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True))
|
||||
elif hasattr(self.request.auth, 'organizer_id'):
|
||||
return Organizer.objects.filter(pk=self.request.auth.organizer_id)
|
||||
else:
|
||||
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)
|
||||
|
||||
16
src/pretix/api/views/user.py
Normal file
16
src/pretix/api/views/user.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
|
||||
class MeView(APIView):
|
||||
authentication_classes = (SessionAuthentication, OAuth2Authentication)
|
||||
|
||||
def get(self, request, format=None):
|
||||
return Response({
|
||||
'email': request.user.email,
|
||||
'fullname': request.user.fullname,
|
||||
'locale': request.user.locale,
|
||||
'timezone': request.user.timezone
|
||||
})
|
||||
@@ -1,15 +1,19 @@
|
||||
import contextlib
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import (
|
||||
BooleanFilter, DjangoFilterBackend, FilterSet,
|
||||
)
|
||||
from rest_framework import viewsets
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import list_route
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.voucher import VoucherSerializer
|
||||
from pretix.base.models import Voucher
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
|
||||
|
||||
class VoucherFilter(FilterSet):
|
||||
@@ -35,15 +39,36 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering = ('id',)
|
||||
ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value')
|
||||
filter_class = VoucherFilter
|
||||
filterset_class = VoucherFilter
|
||||
permission = 'can_view_vouchers'
|
||||
write_permission = 'can_change_vouchers'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.vouchers.all()
|
||||
|
||||
def _predict_quota_check(self, data, instance):
|
||||
# This method predicts if Voucher.clean_quota_needs_checking
|
||||
# *migh* later require a quota check. It is only approximate
|
||||
# and returns True a little too often. The point is to avoid
|
||||
# locks when we know we won't need them.
|
||||
if 'allow_ignore_quota' in data and data.get('allow_ignore_quota'):
|
||||
return False
|
||||
if instance and 'allow_ignore_quota' not in data and instance.allow_ignore_quota:
|
||||
return False
|
||||
|
||||
if 'block_quota' in data and not data.get('block_quota'):
|
||||
return False
|
||||
if instance and 'block_quota' not in data and not instance.block_quota:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
with request.event.lock():
|
||||
if self._predict_quota_check(request.data, None):
|
||||
lockfn = request.event.lock
|
||||
else:
|
||||
lockfn = contextlib.suppress # noop context manager
|
||||
with lockfn():
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -51,7 +76,7 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.voucher.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -61,7 +86,11 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
return ctx
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
with request.event.lock():
|
||||
if self._predict_quota_check(request.data, self.get_object()):
|
||||
lockfn = request.event.lock
|
||||
else:
|
||||
lockfn = contextlib.suppress # noop context manager
|
||||
with lockfn():
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -69,7 +98,7 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
serializer.instance.log_action(
|
||||
'pretix.voucher.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@@ -80,6 +109,27 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
instance.log_action(
|
||||
'pretix.voucher.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@list_route(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
|
||||
else:
|
||||
lockfn = contextlib.suppress # noop context manager
|
||||
with lockfn():
|
||||
serializer = self.get_serializer(data=request.data, many=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
serializer.save(event=self.request.event)
|
||||
for i, v in enumerate(serializer.instance):
|
||||
v.log_action(
|
||||
'pretix.voucher.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data[i]
|
||||
)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.waitinglist import WaitingListSerializer
|
||||
from pretix.base.models import WaitingListEntry
|
||||
from pretix.base.models.waitinglist import WaitingListException
|
||||
|
||||
|
||||
class WaitingListFilter(FilterSet):
|
||||
@@ -18,14 +22,61 @@ class WaitingListFilter(FilterSet):
|
||||
fields = ['item', 'variation', 'email', 'locale', 'has_voucher', 'subevent']
|
||||
|
||||
|
||||
class WaitingListViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class WaitingListViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = WaitingListSerializer
|
||||
queryset = WaitingListEntry.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering = ('created',)
|
||||
ordering_fields = ('id', 'created', 'email', 'item')
|
||||
filter_class = WaitingListFilter
|
||||
filterset_class = WaitingListFilter
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.waitinglistentries.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.orders.waitinglist.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
if serializer.instance.voucher:
|
||||
raise PermissionDenied('This entry can not be changed as it has already been assigned a voucher.')
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.orders.waitinglist.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if instance.voucher:
|
||||
raise PermissionDenied('This entry can not be deleted as it has already been assigned a voucher.')
|
||||
|
||||
instance.log_action(
|
||||
'pretix.event.orders.waitinglist.deleted',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def send_voucher(self, *args, **kwargs):
|
||||
try:
|
||||
self.get_object().send_voucher(
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
except WaitingListException as e:
|
||||
raise ValidationError(str(e))
|
||||
else:
|
||||
return Response(status=204)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user