forked from CGM_Public/pretix_original
Compare commits
555 Commits
v4.1.0.pos
...
release/4.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
013da0a6aa | ||
|
|
a7c59374db | ||
|
|
23e1dac5da | ||
|
|
5cd8845728 | ||
|
|
34dfc35032 | ||
|
|
c9aaa343e6 | ||
|
|
87a196c4df | ||
|
|
a220f1678b | ||
|
|
c8fa0852b2 | ||
|
|
fe3433106c | ||
|
|
f8086daf34 | ||
|
|
66f75a5614 | ||
|
|
6f30c347c0 | ||
|
|
3596fa9c5a | ||
|
|
e3c7cd7c6d | ||
|
|
194042dca5 | ||
|
|
3be6e83f33 | ||
|
|
4262bce2b5 | ||
|
|
73ab962e16 | ||
|
|
13a86fc6f3 | ||
|
|
9d6f11718a | ||
|
|
c9d3428996 | ||
|
|
d4ef16b31a | ||
|
|
6a35e7d3cd | ||
|
|
463443d606 | ||
|
|
6f0da5c2ca | ||
|
|
c1344422a5 | ||
|
|
c2bd3dde44 | ||
|
|
9e51736232 | ||
|
|
5b27ce1265 | ||
|
|
0757542f4f | ||
|
|
12be98c888 | ||
|
|
51e6b02aa9 | ||
|
|
acc4a167b1 | ||
|
|
dd9429bbfa | ||
|
|
768bb8c106 | ||
|
|
cbdafac999 | ||
|
|
96f694cf61 | ||
|
|
5576829ebf | ||
|
|
b0d67e92ac | ||
|
|
63e28723d2 | ||
|
|
cc0656f169 | ||
|
|
849c8e719a | ||
|
|
a3ec2a4061 | ||
|
|
00a7187a7a | ||
|
|
701c4f768e | ||
|
|
cf751d38d2 | ||
|
|
888402a4bf | ||
|
|
1134f610fd | ||
|
|
8ae4304c7d | ||
|
|
357092ec44 | ||
|
|
70a5c76d79 | ||
|
|
7a4db8ea23 | ||
|
|
223b160c0c | ||
|
|
30c1771d29 | ||
|
|
b3b7b9bbab | ||
|
|
be040cd6ea | ||
|
|
c6665ec2e6 | ||
|
|
fd16ef1e4d | ||
|
|
39557fc452 | ||
|
|
408397a639 | ||
|
|
d4a2500204 | ||
|
|
e74d9e56cf | ||
|
|
f3767ab4ac | ||
|
|
5d13f5f885 | ||
|
|
451d3fce05 | ||
|
|
ccb61e0f56 | ||
|
|
b6273adc57 | ||
|
|
0bf7bba6ba | ||
|
|
7090e0bae2 | ||
|
|
c75cb0b8e3 | ||
|
|
3dbf22f670 | ||
|
|
f26cbdc257 | ||
|
|
6b4adccee5 | ||
|
|
c2a8286022 | ||
|
|
4145887a9b | ||
|
|
9f4b834abc | ||
|
|
8fcc314f09 | ||
|
|
94a7d02ab1 | ||
|
|
ad2943263c | ||
|
|
5210ac3a78 | ||
|
|
0e9600a7bf | ||
|
|
eccba09452 | ||
|
|
c8a830ecde | ||
|
|
aed64d16f6 | ||
|
|
d16f6167f6 | ||
|
|
77d59248e5 | ||
|
|
a0e05f8af6 | ||
|
|
9b8a47c8b8 | ||
|
|
b3d692276c | ||
|
|
55543e12f6 | ||
|
|
1e16185c02 | ||
|
|
cd900e24bd | ||
|
|
0dbedc07ce | ||
|
|
f71877b7fc | ||
|
|
f69e270e4d | ||
|
|
533939cae4 | ||
|
|
91ec5fd78c | ||
|
|
0056fb447b | ||
|
|
20c4d12e98 | ||
|
|
e13c567e84 | ||
|
|
9fef97a7c6 | ||
|
|
e68a995376 | ||
|
|
6abdb40ef5 | ||
|
|
43cc06b0a1 | ||
|
|
d17476cd75 | ||
|
|
5c3bfd2a71 | ||
|
|
033b8d70e7 | ||
|
|
bd22c2afc9 | ||
|
|
b355733f53 | ||
|
|
e1f924c4ce | ||
|
|
8038f4e173 | ||
|
|
5c55219d45 | ||
|
|
bfd37af467 | ||
|
|
b2509e120c | ||
|
|
e2339acd09 | ||
|
|
c15b4fa03c | ||
|
|
c4aa2e0484 | ||
|
|
361eeb7159 | ||
|
|
0109e1806f | ||
|
|
30aadac099 | ||
|
|
0458f1b2dc | ||
|
|
e006ca3feb | ||
|
|
1f31ee2ea1 | ||
|
|
2d37b0df77 | ||
|
|
4133e5ac4d | ||
|
|
0fd3d0fe71 | ||
|
|
d0685e99ad | ||
|
|
c6fd5bc864 | ||
|
|
9fa935099f | ||
|
|
83b5a325e3 | ||
|
|
97e12c5003 | ||
|
|
e6db8340f2 | ||
|
|
3cf9caa5d3 | ||
|
|
2ffd68ace7 | ||
|
|
0231be63b4 | ||
|
|
fae8bc254e | ||
|
|
1d5c700fa2 | ||
|
|
e61775d5c1 | ||
|
|
e767c6a68d | ||
|
|
832235411f | ||
|
|
1f0f7b752f | ||
|
|
3117eceb72 | ||
|
|
c1b39782fd | ||
|
|
860cfc3227 | ||
|
|
45859a07dd | ||
|
|
04fb8efc0d | ||
|
|
fdb8a3720b | ||
|
|
5638d68894 | ||
|
|
f64042280a | ||
|
|
50060cdc8d | ||
|
|
4499f58e3d | ||
|
|
918e4a5a89 | ||
|
|
15a86fd796 | ||
|
|
4126d20f1c | ||
|
|
ea3edf83f8 | ||
|
|
9a42819b56 | ||
|
|
3e4ba28700 | ||
|
|
9014ffcc28 | ||
|
|
48f4bcf88c | ||
|
|
b7dfb3697e | ||
|
|
475a5be351 | ||
|
|
8254d8f5cc | ||
|
|
6f0f4755ef | ||
|
|
910a35dedc | ||
|
|
e694bd8c21 | ||
|
|
29cf384c28 | ||
|
|
492288f437 | ||
|
|
34e4f7e0fc | ||
|
|
f6f3bbcce6 | ||
|
|
16054893ed | ||
|
|
f6038d2c39 | ||
|
|
8d13b51271 | ||
|
|
83e1f365c2 | ||
|
|
146e1aeb67 | ||
|
|
f9b2920984 | ||
|
|
2c01b214a7 | ||
|
|
fdab45e5ce | ||
|
|
9d2cf18543 | ||
|
|
2206ab1d35 | ||
|
|
ecd2c80dce | ||
|
|
3387df491a | ||
|
|
b6974e0c77 | ||
|
|
31751cbd79 | ||
|
|
993da5a392 | ||
|
|
72455209bb | ||
|
|
803aa0b70d | ||
|
|
954d86337c | ||
|
|
38a58d62f3 | ||
|
|
e67b39a57b | ||
|
|
148b67ac3f | ||
|
|
d261cb3b6b | ||
|
|
169a6c51b4 | ||
|
|
245ad644ff | ||
|
|
4fdce0d126 | ||
|
|
a542bc7a5a | ||
|
|
3164919923 | ||
|
|
8085311eb6 | ||
|
|
3887a65961 | ||
|
|
b229c6156a | ||
|
|
c45298544e | ||
|
|
7bb9d3fc3d | ||
|
|
8607df5a9c | ||
|
|
c4150473fc | ||
|
|
172b2f74e0 | ||
|
|
9586f71dc2 | ||
|
|
25692d180f | ||
|
|
ae047037dc | ||
|
|
265106034b | ||
|
|
dd0a4df914 | ||
|
|
b0ae40c264 | ||
|
|
ad95815043 | ||
|
|
f68522ec0d | ||
|
|
b831e57351 | ||
|
|
51166786ee | ||
|
|
909e7906ff | ||
|
|
e185d5f0e7 | ||
|
|
ce8edf621b | ||
|
|
e58b512876 | ||
|
|
d1754f6d1b | ||
|
|
ff2f1b7424 | ||
|
|
fb1838a2f0 | ||
|
|
d7b05063a4 | ||
|
|
f64a42d61a | ||
|
|
c1994e89a5 | ||
|
|
f37de1ad2f | ||
|
|
e1ff6f8590 | ||
|
|
a5dd22eb4d | ||
|
|
19cde63505 | ||
|
|
754d4f4f62 | ||
|
|
e433230573 | ||
|
|
f8927396d3 | ||
|
|
60be99fbb2 | ||
|
|
0c508c5ba4 | ||
|
|
ea6067ab3f | ||
|
|
9d0fa84277 | ||
|
|
a6835d3b14 | ||
|
|
9ff565f772 | ||
|
|
5d41b20bae | ||
|
|
03de0d5d2e | ||
|
|
2937acdc66 | ||
|
|
6fd09e99e2 | ||
|
|
290e14689d | ||
|
|
89c937089b | ||
|
|
0e02febe76 | ||
|
|
771f822e5f | ||
|
|
e8936551c0 | ||
|
|
ea0f6dfc54 | ||
|
|
abeddd360e | ||
|
|
c209d195bf | ||
|
|
35c46d320c | ||
|
|
30621568ab | ||
|
|
403c4f4499 | ||
|
|
884bba0088 | ||
|
|
2b52edd5b7 | ||
|
|
a4aed96784 | ||
|
|
4bdfd56264 | ||
|
|
31f0b07325 | ||
|
|
3f08f3a7f4 | ||
|
|
93263e7567 | ||
|
|
69cf62d2ca | ||
|
|
bb353e5fde | ||
|
|
2dceff1218 | ||
|
|
5ea8a8ef82 | ||
|
|
03a7a3303c | ||
|
|
2beb0b20ca | ||
|
|
24eea02e0d | ||
|
|
15ab9c72d3 | ||
|
|
c957d77fe0 | ||
|
|
7697018ca4 | ||
|
|
3980a7b2a7 | ||
|
|
035bb56386 | ||
|
|
837b03fff3 | ||
|
|
d3dec72831 | ||
|
|
3d78f68d94 | ||
|
|
faa43d4df8 | ||
|
|
78917afa1a | ||
|
|
4b53d39e3e | ||
|
|
02db07cd25 | ||
|
|
19fb6c8c34 | ||
|
|
0c25b2df92 | ||
|
|
6a543e4557 | ||
|
|
846527546a | ||
|
|
c8cdb2b311 | ||
|
|
96ff3d532d | ||
|
|
8ebba9de86 | ||
|
|
c4e71011ee | ||
|
|
e71ad4bfba | ||
|
|
05a5a69128 | ||
|
|
bb83cd2f39 | ||
|
|
df26171ff1 | ||
|
|
da937dc4e3 | ||
|
|
bb9508ad96 | ||
|
|
41fed7d6a2 | ||
|
|
f441e9984d | ||
|
|
05c6155f37 | ||
|
|
3c096325bd | ||
|
|
d06a352df5 | ||
|
|
ba7b1bb89e | ||
|
|
3dcfa57b70 | ||
|
|
cc13ca1c3f | ||
|
|
aac67ebf83 | ||
|
|
b51e1cfc6f | ||
|
|
f0508cdcc3 | ||
|
|
9ed2dc7b46 | ||
|
|
0e568a3fca | ||
|
|
7f3606ee81 | ||
|
|
b22d43860a | ||
|
|
0f9b339f01 | ||
|
|
cd1e9c1740 | ||
|
|
aec1ce53fc | ||
|
|
aae129be6a | ||
|
|
b906fe0fc3 | ||
|
|
f0f1537e9c | ||
|
|
7b7e77d497 | ||
|
|
9ac705cd88 | ||
|
|
01d9574ddf | ||
|
|
8121167d5e | ||
|
|
dde4e12ce1 | ||
|
|
6cd32400ae | ||
|
|
8fa71ccad4 | ||
|
|
0f47bff5cd | ||
|
|
f459f1f12d | ||
|
|
65167cc290 | ||
|
|
bc7300c393 | ||
|
|
d8450202fe | ||
|
|
41d2bcc34f | ||
|
|
0e1589013a | ||
|
|
39f81617e1 | ||
|
|
b394ef6de1 | ||
|
|
177906e2ac | ||
|
|
59f6b20129 | ||
|
|
51998e820d | ||
|
|
e803b56716 | ||
|
|
fa8b1c176b | ||
|
|
2598787602 | ||
|
|
003fa62996 | ||
|
|
798c21955e | ||
|
|
fe6185af4b | ||
|
|
7bacefa442 | ||
|
|
04e187c297 | ||
|
|
9f2ffc3276 | ||
|
|
a9a4cf6fca | ||
|
|
a563316e22 | ||
|
|
4b6f55c31d | ||
|
|
21a8fad17a | ||
|
|
7586df9d3f | ||
|
|
1d4afa5d27 | ||
|
|
720d9b924e | ||
|
|
9f56669f2a | ||
|
|
fc541016c6 | ||
|
|
5eefe9ad1e | ||
|
|
1d065a7672 | ||
|
|
101f5f7781 | ||
|
|
af7c6d360f | ||
|
|
8751e6e5ba | ||
|
|
93004a8125 | ||
|
|
adf40e1d56 | ||
|
|
364cfe0131 | ||
|
|
1514527ef3 | ||
|
|
680024234d | ||
|
|
2a3660f2d1 | ||
|
|
2041d1213a | ||
|
|
42a1fe9bd1 | ||
|
|
002469d523 | ||
|
|
5be4af1305 | ||
|
|
0b241438e1 | ||
|
|
61649ab2b8 | ||
|
|
848ea999c5 | ||
|
|
dfa82870fb | ||
|
|
e05ac7ef34 | ||
|
|
ad2334bffc | ||
|
|
17adde99fa | ||
|
|
4789d82c4e | ||
|
|
0567e2d22b | ||
|
|
2e0592b0a6 | ||
|
|
7f6d234b4c | ||
|
|
0436de316b | ||
|
|
e16d643d2a | ||
|
|
bdec22cf3b | ||
|
|
b38df27dce | ||
|
|
b95f556d8f | ||
|
|
851a4c977c | ||
|
|
7bffd461d1 | ||
|
|
9a3b4f7863 | ||
|
|
673a38ddc8 | ||
|
|
a27b8bf213 | ||
|
|
36e6f10b37 | ||
|
|
fde10d7f55 | ||
|
|
6b44b2f429 | ||
|
|
5e9018e0fd | ||
|
|
185f8066ae | ||
|
|
6388f7b29c | ||
|
|
4aa2c9d51d | ||
|
|
ef9256f0b0 | ||
|
|
28d78e40f9 | ||
|
|
89554a82eb | ||
|
|
ae99e82ad1 | ||
|
|
5ea3d01b8d | ||
|
|
aa2bd79b99 | ||
|
|
44ee35b885 | ||
|
|
22b79a8c22 | ||
|
|
65bbd537e6 | ||
|
|
34387d7bc0 | ||
|
|
ca38204313 | ||
|
|
b7083eca2e | ||
|
|
6bb8b428dc | ||
|
|
677142d0c9 | ||
|
|
d1b66e365a | ||
|
|
50154c02ce | ||
|
|
04375d4fcf | ||
|
|
9c1ff296bb | ||
|
|
0b3acb06b5 | ||
|
|
b2cdccedd6 | ||
|
|
7ebefa7b85 | ||
|
|
c7b5baa185 | ||
|
|
6d08e7a8b0 | ||
|
|
0da2b12646 | ||
|
|
a0693483dc | ||
|
|
29826a9f08 | ||
|
|
36a045020f | ||
|
|
40c2b774aa | ||
|
|
8422b2b4aa | ||
|
|
ae334c4860 | ||
|
|
722f36121d | ||
|
|
529092a4ed | ||
|
|
e7068020d5 | ||
|
|
08acecf37b | ||
|
|
b200ca5ad5 | ||
|
|
e564952148 | ||
|
|
f4ad2a2293 | ||
|
|
854bbf26c2 | ||
|
|
ec5a670ea6 | ||
|
|
276add9163 | ||
|
|
de977f4818 | ||
|
|
a4827fc992 | ||
|
|
9a002bf172 | ||
|
|
9a7f3e2d8a | ||
|
|
e7546a7575 | ||
|
|
434719285b | ||
|
|
5bc9ba4641 | ||
|
|
74dd13abd5 | ||
|
|
ead755aa86 | ||
|
|
1f46a8b91b | ||
|
|
eb77c2f6f6 | ||
|
|
c5fe615be5 | ||
|
|
f5504e11ac | ||
|
|
e88a1a52f9 | ||
|
|
b86d54ea9f | ||
|
|
b6e2ed14db | ||
|
|
c513868afa | ||
|
|
3f7664f743 | ||
|
|
e654b951ed | ||
|
|
b5c7556abe | ||
|
|
53e3619140 | ||
|
|
e191988b81 | ||
|
|
bb7fd9423b | ||
|
|
c10c6ee28d | ||
|
|
3c64733e93 | ||
|
|
08cb045f2e | ||
|
|
7bf854fe0b | ||
|
|
f2a1e11b85 | ||
|
|
9b07912b7f | ||
|
|
e1cec9882a | ||
|
|
1ff9c1a84b | ||
|
|
0035825f33 | ||
|
|
1ec73b1b33 | ||
|
|
ed83f4558e | ||
|
|
b18ec7605a | ||
|
|
f96bc0776d | ||
|
|
629bdcd55d | ||
|
|
829fd907a1 | ||
|
|
d7fe321f36 | ||
|
|
517432319e | ||
|
|
edef9f1b23 | ||
|
|
617730ab76 | ||
|
|
7c17d041f4 | ||
|
|
9295abb80e | ||
|
|
4c3192f116 | ||
|
|
9c6a2eb85a | ||
|
|
bcbc8a542f | ||
|
|
a915442efc | ||
|
|
103631a14b | ||
|
|
e9d7a24cbf | ||
|
|
cc977e441a | ||
|
|
add9bae018 | ||
|
|
77d157ab8e | ||
|
|
e42bc94329 | ||
|
|
b4bf5f998e | ||
|
|
dc785e9dac | ||
|
|
8f5f95b04e | ||
|
|
c86839ed41 | ||
|
|
8b6e0f0de7 | ||
|
|
a65243e4bb | ||
|
|
ac028be84e | ||
|
|
efd5b5b1da | ||
|
|
4be618bc93 | ||
|
|
7b6d5a0cc9 | ||
|
|
f367d5e675 | ||
|
|
f9b7894c4d | ||
|
|
354bbb485b | ||
|
|
8dc5dbd547 | ||
|
|
e04793d2eb | ||
|
|
db65c14733 | ||
|
|
f10c8b229f | ||
|
|
4655d8237f | ||
|
|
78f4f35ca3 | ||
|
|
3a01a05a08 | ||
|
|
1738c710cb | ||
|
|
d07783a453 | ||
|
|
1ce331f163 | ||
|
|
586f95bc6d | ||
|
|
5620aec5f2 | ||
|
|
c1dfec20f6 | ||
|
|
7fef81bdef | ||
|
|
0f2e905672 | ||
|
|
a57a4e7350 | ||
|
|
b57a6e982a | ||
|
|
39736ef0d4 | ||
|
|
f7e5f0b567 | ||
|
|
b6078d5272 | ||
|
|
1ed1cd33e8 | ||
|
|
a4a2500725 | ||
|
|
3fb44ec9dd | ||
|
|
2a96575b4d | ||
|
|
dcf29ec63e | ||
|
|
a743605bd3 | ||
|
|
75dc80eb09 | ||
|
|
ac16d9d900 | ||
|
|
736d26c232 | ||
|
|
8985dfc5eb | ||
|
|
bb80ef067a | ||
|
|
bdd9751f0e | ||
|
|
965aac6ad5 | ||
|
|
e3858373d1 | ||
|
|
fcdfae88d7 | ||
|
|
7d5a85e26f | ||
|
|
b8b2c2eba3 | ||
|
|
c6a3280d69 | ||
|
|
7f9368c415 | ||
|
|
add764e3f0 | ||
|
|
a3431cd51e | ||
|
|
9772d43235 | ||
|
|
2e29e369f5 | ||
|
|
9f6ce81229 | ||
|
|
d67954de3f | ||
|
|
d04f93d45c | ||
|
|
ef70209ba8 | ||
|
|
f127cfc46a | ||
|
|
ec444e5bf3 | ||
|
|
32f690e9d0 | ||
|
|
9089b630ed | ||
|
|
0c6971ff5f | ||
|
|
59e92245de | ||
|
|
be726183cb |
15
.github/dependabot.yml
vendored
Normal file
15
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/src"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/src/pretix/static/npm_dir"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
14
.github/workflows/tests.yml
vendored
14
.github/workflows/tests.yml
vendored
@@ -18,17 +18,17 @@ jobs:
|
|||||||
name: Tests
|
name: Tests
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.6, 3.7, 3.8]
|
python-version: ["3.7", "3.8", "3.9"]
|
||||||
database: [sqlite, postgres, mysql]
|
database: [sqlite, postgres, mysql]
|
||||||
exclude:
|
exclude:
|
||||||
- database: mysql
|
- database: mysql
|
||||||
python-version: 3.7
|
python-version: "3.8"
|
||||||
- database: sqlite
|
|
||||||
python-version: 3.7
|
|
||||||
- database: mysql
|
- database: mysql
|
||||||
python-version: 3.6
|
python-version: "3.9"
|
||||||
- database: sqlite
|
- database: sqlite
|
||||||
python-version: 3.6
|
python-version: "3.7"
|
||||||
|
- database: sqlite
|
||||||
|
python-version: "3.8"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: getong/mariadb-action@v1.1
|
- uses: getong/mariadb-action@v1.1
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pip-
|
${{ runner.os }}-pip-
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: sudo apt update && sudo apt install gettext mysql-client
|
run: sudo apt update && sudo apt install gettext mariadb-client
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -1,9 +1,9 @@
|
|||||||
FROM python:3.8
|
FROM python:3.9-bullseye
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
default-libmysqlclient-dev \
|
libmariadb-dev \
|
||||||
gettext \
|
gettext \
|
||||||
git \
|
git \
|
||||||
libffi-dev \
|
libffi-dev \
|
||||||
@@ -15,8 +15,7 @@ RUN apt-get update && \
|
|||||||
libxslt1-dev \
|
libxslt1-dev \
|
||||||
locales \
|
locales \
|
||||||
nginx \
|
nginx \
|
||||||
python-dev \
|
python3-virtualenv \
|
||||||
python-virtualenv \
|
|
||||||
python3-dev \
|
python3-dev \
|
||||||
sudo \
|
sudo \
|
||||||
supervisor \
|
supervisor \
|
||||||
@@ -57,10 +56,11 @@ COPY deployment/docker/supervisord /etc/supervisord
|
|||||||
COPY deployment/docker/supervisord.all.conf /etc/supervisord.all.conf
|
COPY deployment/docker/supervisord.all.conf /etc/supervisord.all.conf
|
||||||
COPY deployment/docker/supervisord.web.conf /etc/supervisord.web.conf
|
COPY deployment/docker/supervisord.web.conf /etc/supervisord.web.conf
|
||||||
COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
|
COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY deployment/docker/nginx-max-body-size.conf /etc/nginx/conf.d/nginx-max-body-size.conf
|
||||||
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
|
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
|
||||||
COPY src /pretix/src
|
COPY src /pretix/src
|
||||||
|
|
||||||
RUN cd /pretix/src && pip3 install .
|
RUN cd /pretix/src && python setup.py install
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/pretix && \
|
RUN chmod +x /usr/local/bin/pretix && \
|
||||||
rm /etc/nginx/sites-enabled/default && \
|
rm /etc/nginx/sites-enabled/default && \
|
||||||
|
|||||||
1
deployment/docker/nginx-max-body-size.conf
Normal file
1
deployment/docker/nginx-max-body-size.conf
Normal file
@@ -0,0 +1 @@
|
|||||||
|
client_max_body_size 100M;
|
||||||
@@ -16,7 +16,6 @@ http {
|
|||||||
charset utf-8;
|
charset utf-8;
|
||||||
tcp_nopush on;
|
tcp_nopush on;
|
||||||
tcp_nodelay on;
|
tcp_nodelay on;
|
||||||
client_max_body_size 100M;
|
|
||||||
|
|
||||||
log_format private '[$time_local] $host "$request" $status $body_bytes_sent';
|
log_format private '[$time_local] $host "$request" $status $body_bytes_sent';
|
||||||
|
|
||||||
@@ -66,9 +65,18 @@ http {
|
|||||||
access_log off;
|
access_log off;
|
||||||
expires 365d;
|
expires 365d;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
gzip on;
|
||||||
}
|
}
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://unix:/tmp/pretix.sock:/;
|
# Very important:
|
||||||
|
# proxy_pass http://unix:/tmp/pretix.sock:;
|
||||||
|
# is not the same as
|
||||||
|
# proxy_pass http://unix:/tmp/pretix.sock:/;
|
||||||
|
# In the latter case, nginx will apply its URL parsing, in the former it doesn't.
|
||||||
|
# There are situations in which pretix' API will deal with "file names" containing %2F%2F, which
|
||||||
|
# nginx will normalize to %2F, which can break ticket validation.
|
||||||
|
proxy_pass http://unix:/tmp/pretix.sock:;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,12 +220,30 @@ Example::
|
|||||||
``user``, ``password``
|
``user``, ``password``
|
||||||
The SMTP user data to use for the connection. Empty by default.
|
The SMTP user data to use for the connection. Empty by default.
|
||||||
|
|
||||||
|
``tls``, ``ssl``
|
||||||
|
Use STARTTLS or SSL for the SMTP connection. Off by default.
|
||||||
|
|
||||||
``from``
|
``from``
|
||||||
The email address to set as ``From`` header in outgoing emails by the system.
|
The email address to set as ``From`` header in outgoing emails by the system.
|
||||||
Default: ``pretix@localhost``
|
Default: ``pretix@localhost``
|
||||||
|
|
||||||
``tls``, ``ssl``
|
``from_notifications``
|
||||||
Use STARTTLS or SSL for the SMTP connection. Off by default.
|
The email address to set as ``From`` header in admin notification emails by the system.
|
||||||
|
Defaults to the value of ``from``.
|
||||||
|
|
||||||
|
``from_organizers``
|
||||||
|
The email address to set as ``From`` header in outgoing emails by the system sent on behalf of organizers.
|
||||||
|
Defaults to the value of ``from``.
|
||||||
|
|
||||||
|
``custom_sender_verification_required``
|
||||||
|
If this is on (the default), organizers need to verify email addresses they want to use as senders in their event.
|
||||||
|
|
||||||
|
``custom_sender_spf_string``
|
||||||
|
If this is set to a valid SPF string, pretix will show a warning if organizers use a sender address from a domain
|
||||||
|
that does not include this value.
|
||||||
|
|
||||||
|
``custom_smtp_allow_private_networks``
|
||||||
|
If this is off (the default), custom SMTP servers cannot be private network addresses.
|
||||||
|
|
||||||
``admins``
|
``admins``
|
||||||
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
|
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
|
||||||
@@ -282,7 +300,7 @@ You can use an existing memcached server as pretix's caching backend::
|
|||||||
``location``
|
``location``
|
||||||
The location of memcached, either a host:port combination or a socket file.
|
The location of memcached, either a host:port combination or a socket file.
|
||||||
|
|
||||||
If no memcached is configured, pretix will use Django's built-in local-memory caching method.
|
If no memcached is configured, pretix will use redis for caching. If neither is configured, pretix will not use any caching.
|
||||||
|
|
||||||
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
|
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
|
||||||
shared memcached instance, not multiple ones, because cache invalidations would not be
|
shared memcached instance, not multiple ones, because cache invalidations would not be
|
||||||
@@ -434,3 +452,21 @@ pretix can make use of some external tools if they are installed. Currently, the
|
|||||||
|
|
||||||
.. _Python documentation: https://docs.python.org/3/library/configparser.html?highlight=configparser#supported-ini-file-structure
|
.. _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
|
.. _Celery documentation: http://docs.celeryproject.org/en/latest/userguide/configuration.html
|
||||||
|
|
||||||
|
Maximum upload file sizes
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
You can configure the maximum file size for uploading various files::
|
||||||
|
|
||||||
|
[pretix_file_upload]
|
||||||
|
; Max upload size for images in MiB, defaults to 10 MiB
|
||||||
|
max_size_image = 12
|
||||||
|
; Max upload size for favicons in MiB, defaults to 1 MiB
|
||||||
|
max_size_favicon = 2
|
||||||
|
; Max upload size for email attachments of manually sent emails in MiB, defaults to 10 MiB
|
||||||
|
max_size_email_attachment = 15
|
||||||
|
; Max upload size for email attachments of automatically sent emails in MiB, defaults to 1 MiB
|
||||||
|
max_size_email_auto_attachment = 2
|
||||||
|
; Max upload size for other files in MiB, defaults to 10 MiB
|
||||||
|
; This includes all file upload type order questions
|
||||||
|
max_size_other = 100
|
||||||
|
|||||||
40
doc/admin/errors.rst
Normal file
40
doc/admin/errors.rst
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
.. _`admin-errors`:
|
||||||
|
|
||||||
|
Dealing with errors
|
||||||
|
===================
|
||||||
|
|
||||||
|
If you encounter an error in pretix, please follow the following steps to debug it:
|
||||||
|
|
||||||
|
* If the error message is shown on a **white page** and the last line of the error includes "nginx", the error is not with pretix
|
||||||
|
directly but with your nginx webserver. This might mean that pretix is not running, but it could also be something else.
|
||||||
|
Please first check your nginx error log. The default location is ``/var/log/nginx/error.log``.
|
||||||
|
|
||||||
|
* If it turns out pretix is not running, check the output of ``docker logs pretix`` for a docker installation and
|
||||||
|
``journalctl -u pretix-web.service`` for a manual installation.
|
||||||
|
|
||||||
|
* If the error message is an "**Internal Server Error**" in purple pretix design, please check pretix' log file which by default is at
|
||||||
|
``/var/pretix-data/logs/pretix.log`` if you installed with docker and ``/var/pretix/data/logs/pretix.log`` otherwise. If you don't
|
||||||
|
know how to interpret it, open a discussion on GitHub with the relevant parts of the log file.
|
||||||
|
|
||||||
|
* If the error message includes ``/usr/bin/env: ‘node’: No such file or directory``, you forgot to install ``node.js``
|
||||||
|
|
||||||
|
* If the error message includes ``OfflineGenerationError``, you might have forgot to run the ``rebuild`` step after a pretix update
|
||||||
|
or plugin installation.
|
||||||
|
|
||||||
|
* If the error message mentions your database server or redis server, make sure these are running and accessible.
|
||||||
|
|
||||||
|
* If pretix loads fine but certain actions (creating carts, orders, or exports, downloading tickets, sending emails) **take forever**,
|
||||||
|
``pretix-worker`` is not running. Check the output of ``docker logs pretix`` for a docker installation and
|
||||||
|
``journalctl -u pretix-worker.service`` for a manual installation.
|
||||||
|
|
||||||
|
* If the page loads but all **styles are missing**, you probably forgot to update your nginx configuration file after an upgrade of your
|
||||||
|
operating system's python version.
|
||||||
|
|
||||||
|
|
||||||
|
If you are unable to debug the issue any further, please open a **discussion** on GitHub in our `Q&A Forum`_. Do **not** open an issue
|
||||||
|
right away, since most things turn out not to be a bug in pretix but a mistake in your server configuration. Make sure to include
|
||||||
|
relevant log excerpts in your question.
|
||||||
|
|
||||||
|
If you're a pretix Enterprise customer, you can also reach out to support@pretix.eu with your issue right away.
|
||||||
|
|
||||||
|
.. _Q&A Forum: https://github.com/pretix/pretix/discussions/categories/q-a
|
||||||
@@ -9,7 +9,9 @@ This documentation is for everyone who wants to install pretix on a server.
|
|||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
installation/index
|
installation/index
|
||||||
|
updates
|
||||||
config
|
config
|
||||||
maintainance
|
maintainance
|
||||||
scaling
|
scaling
|
||||||
|
errors
|
||||||
indexes
|
indexes
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Here is the currently recommended set of commands::
|
|||||||
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_name
|
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_name
|
||||||
ON pretixbase_orderposition
|
ON pretixbase_orderposition
|
||||||
USING gin (upper("attendee_name_cached") gin_trgm_ops);
|
USING gin (upper("attendee_name_cached") gin_trgm_ops);
|
||||||
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_scret
|
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_secret
|
||||||
ON pretixbase_orderposition
|
ON pretixbase_orderposition
|
||||||
USING gin (upper("secret") gin_trgm_ops);
|
USING gin (upper("secret") gin_trgm_ops);
|
||||||
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_email
|
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_email
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ Linux and firewalls, we recommend that you start with `ufw`_.
|
|||||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
.. 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**.
|
**MariaDB 10.2.7 or newer**.
|
||||||
|
|
||||||
|
.. warning:: By default, using `ufw` in conjunction will not have any effect. Please make sure to either bind the exposed
|
||||||
|
ports of your docker container explicitly to 127.0.0.1 or configure docker to respect any set up firewall
|
||||||
|
rules.
|
||||||
|
|
||||||
On this guide
|
On this guide
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
@@ -183,7 +187,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
|
|||||||
TimeoutStartSec=0
|
TimeoutStartSec=0
|
||||||
ExecStartPre=-/usr/bin/docker kill %n
|
ExecStartPre=-/usr/bin/docker kill %n
|
||||||
ExecStartPre=-/usr/bin/docker rm %n
|
ExecStartPre=-/usr/bin/docker rm %n
|
||||||
ExecStart=/usr/bin/docker run --name %n -p 8345:80 \
|
ExecStart=/usr/bin/docker run --name %n -p 127.0.0.1:8345:80 \
|
||||||
-v /var/pretix-data:/data \
|
-v /var/pretix-data:/data \
|
||||||
-v /etc/pretix:/etc/pretix \
|
-v /etc/pretix:/etc/pretix \
|
||||||
-v /var/run/redis:/var/run/redis \
|
-v /var/run/redis:/var/run/redis \
|
||||||
@@ -233,7 +237,7 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
|||||||
ssl_certificate_key /path/to/key.pem;
|
ssl_certificate_key /path/to/key.pem;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:8345/;
|
proxy_pass http://localhost:8345;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -252,6 +256,8 @@ create an event and start selling tickets!
|
|||||||
|
|
||||||
You should probably read :ref:`maintainance` next.
|
You should probably read :ref:`maintainance` next.
|
||||||
|
|
||||||
|
.. _`docker_updates`:
|
||||||
|
|
||||||
Updates
|
Updates
|
||||||
-------
|
-------
|
||||||
|
|
||||||
@@ -267,6 +273,8 @@ Restarting the service can take a few seconds, especially if the update requires
|
|||||||
Replace ``stable`` above with a specific version number like ``1.0`` or with ``latest`` for the development
|
Replace ``stable`` above with a specific version number like ``1.0`` or with ``latest`` for the development
|
||||||
version, if you want to.
|
version, if you want to.
|
||||||
|
|
||||||
|
Make sure to also read :ref:`update_notes` and the release notes of the version you are updating to.
|
||||||
|
|
||||||
.. _`docker_plugininstall`:
|
.. _`docker_plugininstall`:
|
||||||
|
|
||||||
Install a plugin
|
Install a plugin
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ installation guides):
|
|||||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||||
* A `PostgreSQL`_ 9.6+, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
* A `PostgreSQL`_ 9.6+, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||||
* A `redis`_ server
|
* A `redis`_ server
|
||||||
* A `nodejs_` installation
|
* A `nodejs`_ installation
|
||||||
|
|
||||||
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
||||||
Linux and firewalls, we recommend that you start with `ufw`_.
|
Linux and firewalls, we recommend that you start with `ufw`_.
|
||||||
@@ -72,7 +72,7 @@ To build and run pretix, you will need the following debian packages::
|
|||||||
|
|
||||||
# apt-get install git build-essential python-dev python3-venv python3 python3-pip \
|
# apt-get install git build-essential python-dev python3-venv python3 python3-pip \
|
||||||
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
||||||
gettext libpq-dev libmariadbclient-dev libjpeg-dev libopenjp2-7-dev
|
gettext libpq-dev libmariadb-dev libjpeg-dev libopenjp2-7-dev
|
||||||
|
|
||||||
Config file
|
Config file
|
||||||
-----------
|
-----------
|
||||||
@@ -142,7 +142,7 @@ If you're running MySQL, also install the client library::
|
|||||||
|
|
||||||
(venv)$ pip3 install mysqlclient
|
(venv)$ pip3 install mysqlclient
|
||||||
|
|
||||||
Note that you need Python 3.6 or newer. You can find out your Python version using ``python -V``.
|
Note that you need Python 3.7 or newer. You can find out your Python version using ``python -V``.
|
||||||
|
|
||||||
We also need to create a data directory::
|
We also need to create a data directory::
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
|||||||
add_header X-Content-Type-Options nosniff;
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:8345/;
|
proxy_pass http://localhost:8345;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -259,14 +259,14 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /static/ {
|
location /static/ {
|
||||||
alias /var/pretix/venv/lib/python3.7/site-packages/pretix/static.dist/;
|
alias /var/pretix/venv/lib/python3.10/site-packages/pretix/static.dist/;
|
||||||
access_log off;
|
access_log off;
|
||||||
expires 365d;
|
expires 365d;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.. note:: Remember to replace the ``python3.7`` in the ``/static/`` path in the config
|
.. note:: Remember to replace the ``python3.10`` in the ``/static/`` path in the config
|
||||||
above with your python version.
|
above with your python version.
|
||||||
|
|
||||||
We recommend reading about setting `strong encryption settings`_ for your web server.
|
We recommend reading about setting `strong encryption settings`_ for your web server.
|
||||||
@@ -280,13 +280,14 @@ create an event and start selling tickets!
|
|||||||
|
|
||||||
You should probably read :ref:`maintainance` next.
|
You should probably read :ref:`maintainance` next.
|
||||||
|
|
||||||
|
.. _`manual_updates`:
|
||||||
|
|
||||||
Updates
|
Updates
|
||||||
-------
|
-------
|
||||||
|
|
||||||
.. warning:: While we try hard not to break things, **please perform a backup before every upgrade**.
|
.. warning:: While we try hard not to break things, **please perform a backup before every upgrade**.
|
||||||
|
|
||||||
To upgrade to a new pretix release, pull the latest code changes and run the following commands (again, replace
|
To upgrade to a new pretix release, pull the latest code changes and run the following commands::
|
||||||
``postgres`` with ``mysql`` if necessary)::
|
|
||||||
|
|
||||||
$ source /var/pretix/venv/bin/activate
|
$ source /var/pretix/venv/bin/activate
|
||||||
(venv)$ pip3 install -U --upgrade-strategy eager pretix gunicorn
|
(venv)$ pip3 install -U --upgrade-strategy eager pretix gunicorn
|
||||||
@@ -295,6 +296,7 @@ To upgrade to a new pretix release, pull the latest code changes and run the fol
|
|||||||
(venv)$ python -m pretix updatestyles
|
(venv)$ python -m pretix updatestyles
|
||||||
# systemctl restart pretix-web pretix-worker
|
# systemctl restart pretix-web pretix-worker
|
||||||
|
|
||||||
|
Make sure to also read :ref:`update_notes` and the release notes of the version you are updating to.
|
||||||
|
|
||||||
.. _`manual_plugininstall`:
|
.. _`manual_plugininstall`:
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ If you host your own pretix instance, you also need to care about the availabili
|
|||||||
of your service and the safety of your data yourself. This page gives you some
|
of your service and the safety of your data yourself. This page gives you some
|
||||||
information that you might need to do so properly.
|
information that you might need to do so properly.
|
||||||
|
|
||||||
|
.. _`backups`:
|
||||||
|
|
||||||
Backups
|
Backups
|
||||||
-------
|
-------
|
||||||
|
|
||||||
|
|||||||
51
doc/admin/updates.rst
Normal file
51
doc/admin/updates.rst
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
.. _`update_notes`:
|
||||||
|
|
||||||
|
Update notes
|
||||||
|
============
|
||||||
|
|
||||||
|
pretix receives regular feature and bugfix updates and we highly encourage you to always update to
|
||||||
|
the latest version for maximum quality and security. Updates are announces on our `blog`_. There are
|
||||||
|
usually 10 feature updates in a year, so you can expect a new release almost every month.
|
||||||
|
|
||||||
|
Pure bugfix releases are only issued in case of very critical bugs or security vulnerabilities. In these
|
||||||
|
case, we'll publish bugfix releases for the last three stable release branches.
|
||||||
|
|
||||||
|
Compatibility to plugins and in very rare cases API clients may break. For in-depth details on the
|
||||||
|
API changes of every version, please refer to the release notes published on our blog.
|
||||||
|
|
||||||
|
Upgrade steps
|
||||||
|
-------------
|
||||||
|
|
||||||
|
For the actual upgrade, you can usually just follow the steps from the installation guide for :ref:`manual installations <manual_updates>`
|
||||||
|
or :ref:`docker installations <docker_updates>` respectively.
|
||||||
|
Generally, it is always strongly recommended to perform a :ref:`backup <backups>` first.
|
||||||
|
It is possible to skip versions during updates, although we recommend not skipping over major version numbers
|
||||||
|
(i.e. if you want to go from 2.4 to 4.4, first upgrade to 3.0, then upgrade to 4.0, then to 4.4).
|
||||||
|
|
||||||
|
In addition to these standard update steps, the following list issues steps that should be taken when you upgrade
|
||||||
|
to specific versions for pretix. If you're skipping versions, please read the instructions for every version in
|
||||||
|
between as well.
|
||||||
|
|
||||||
|
Upgrade to 3.17.0 or newer
|
||||||
|
""""""""""""""""""""""""""
|
||||||
|
|
||||||
|
pretix 3.17 introduces a dependency on ``nodejs``, so you should install it on your system::
|
||||||
|
|
||||||
|
# apt install nodejs npm
|
||||||
|
|
||||||
|
Upgrade to 4.4.0 or newer
|
||||||
|
"""""""""""""""""""""""""
|
||||||
|
|
||||||
|
pretix 4.4 introduces a new data structure to store historical financial data. If you already have existing
|
||||||
|
data in your database, you will need to back-fill this data or you might get incorrect reports! This is not
|
||||||
|
done automatically as part of the usual update steps since it can take a while on large databases and you might
|
||||||
|
want to do it in parallel while the system is already running again. Please execute the following command::
|
||||||
|
|
||||||
|
(venv)$ python -m pretix create_order_transactions
|
||||||
|
|
||||||
|
Or, with a docker installation::
|
||||||
|
|
||||||
|
$ docker exec -it pretix.service pretix create_order_transactions
|
||||||
|
|
||||||
|
|
||||||
|
.. _blog: https://pretix.eu/about/en/blog/
|
||||||
@@ -87,7 +87,8 @@ respectively, or ``null`` if there is no such page. You can use those URLs to re
|
|||||||
respective page.
|
respective page.
|
||||||
|
|
||||||
The field ``results`` contains a list of objects representing the first results. For most
|
The field ``results`` contains a list of objects representing the first results. For most
|
||||||
objects, every page contains 50 results.
|
objects, every page contains 50 results. You can specify a lower pagination size using the
|
||||||
|
``page_size`` query parameter, but no more than 50.
|
||||||
|
|
||||||
Conditional fetching
|
Conditional fetching
|
||||||
--------------------
|
--------------------
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ For example, if you want users to be redirected to ``https://example.org/order/r
|
|||||||
either enter ``https://example.org`` or ``https://example.org/order/``.
|
either enter ``https://example.org`` or ``https://example.org/order/``.
|
||||||
|
|
||||||
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
|
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
|
||||||
**regardless of whether it was successful or not**. Make sure you use our API to check if the payment actually
|
**regardless of whether it was successful or not**. We will append an ``error=…`` query parameter with an error
|
||||||
|
message, but you should not rely on that and instead make sure you use our API to check if the payment actually
|
||||||
worked! Your final URL could look like this::
|
worked! Your final URL could look like this::
|
||||||
|
|
||||||
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234
|
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234
|
||||||
|
|||||||
@@ -243,6 +243,99 @@ Cart position endpoints
|
|||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
|
||||||
order.
|
order.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/bulk_create/
|
||||||
|
|
||||||
|
Creates multiple new cart position. This operation is deliberately not atomic, so each cart position can succeed
|
||||||
|
or fail individually, so the response code of the response is not the only thing to look at!
|
||||||
|
|
||||||
|
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
|
||||||
|
|
||||||
|
.. warning:: The same limitations as with the regular creation endpoint apply.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/cartpositions/bulk_create/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: 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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item": 1,
|
||||||
|
"variation": null,
|
||||||
|
"price": "23.00",
|
||||||
|
"attendee_name_parts": {
|
||||||
|
"given_name": "Maria",
|
||||||
|
"family_name": "Miller"
|
||||||
|
},
|
||||||
|
"attendee_email": null,
|
||||||
|
"answers": [
|
||||||
|
{
|
||||||
|
"question": 1,
|
||||||
|
"answer": "23",
|
||||||
|
"options": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subevent": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"errors": null,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"success": "false",
|
||||||
|
"errors": {
|
||||||
|
"non_field_errors": ["There is not enough quota available on quota \"Tickets\" to perform the operation."]
|
||||||
|
},
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer of the event to create positions for
|
||||||
|
:param event: The ``slug`` field of the event to create positions for
|
||||||
|
:statuscode 200: See response for success
|
||||||
|
:statuscode 400: Your input could not be parsed
|
||||||
|
: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)/
|
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/(id)/
|
||||||
|
|
||||||
Deletes a cart position, identified by its internal ID.
|
Deletes a cart position, identified by its internal ID.
|
||||||
|
|||||||
@@ -604,6 +604,8 @@ Order position endpoints
|
|||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
:statuscode 404: The requested order position or check-in list does not exist.
|
:statuscode 404: The requested order position or check-in list does not exist.
|
||||||
|
|
||||||
|
.. _`rest-checkin-redeem`:
|
||||||
|
|
||||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/redeem/
|
.. 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
|
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
|
||||||
@@ -618,8 +620,9 @@ Order position endpoints
|
|||||||
:<json boolean canceled_supported: When this parameter is set to ``true``, the response code ``canceled`` may be
|
:<json boolean canceled_supported: When this parameter is set to ``true``, the response code ``canceled`` may be
|
||||||
returned. Otherwise, canceled orders will return ``unpaid``.
|
returned. Otherwise, canceled orders will return ``unpaid``.
|
||||||
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
|
:<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
|
:<json boolean force: Specifies that the check-in should succeed regardless of revoked barcode, previous check-ins or required
|
||||||
questions that have not been filled. Defaults to ``false``.
|
questions that have not been filled. This is usually used to upload offline scans that already happened,
|
||||||
|
because there's no point in validating them since they happened whether they are valid or not. Defaults to ``false``.
|
||||||
:<json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
|
:<json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
|
||||||
:<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
|
:<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
|
||||||
Defaults to ``false`` and only works when ``include_pending`` is set on the check-in
|
Defaults to ``false`` and only works when ``include_pending`` is set on the check-in
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ Resources and endpoints
|
|||||||
webhooks
|
webhooks
|
||||||
seatingplans
|
seatingplans
|
||||||
exporters
|
exporters
|
||||||
|
sendmail_rules
|
||||||
billing_invoices
|
billing_invoices
|
||||||
billing_var
|
billing_var
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ lines list of objects The actual invo
|
|||||||
created before this field was introduced as well as for
|
created before this field was introduced as well as for
|
||||||
all lines not created by a product (e.g. a shipping or
|
all lines not created by a product (e.g. a shipping or
|
||||||
cancellation fee).
|
cancellation fee).
|
||||||
|
├ subevent integer Event series date ID used to create this line. Note that everything
|
||||||
|
about the subevent might have changed since the creation
|
||||||
|
of the invoice. Can be ``null`` for all invoice lines
|
||||||
|
created before this field was introduced as well as for
|
||||||
|
all lines not created by a product (e.g. a shipping or
|
||||||
|
cancellation fee) as well as for all events that are not a series.
|
||||||
├ fee_type string Fee type, e.g. ``shipping``, ``service``, ``payment``,
|
├ fee_type string Fee type, e.g. ``shipping``, ``service``, ``payment``,
|
||||||
``cancellation``, ``giftcard``, or ``other. Can be ``null`` for
|
``cancellation``, ``giftcard``, or ``other. Can be ``null`` for
|
||||||
all invoice lines
|
all invoice lines
|
||||||
@@ -78,6 +84,12 @@ lines list of objects The actual invo
|
|||||||
an event series not created by a product (e.g. shipping or
|
an event series not created by a product (e.g. shipping or
|
||||||
cancellation fees) as well as whenever the respective (sub)event
|
cancellation fees) as well as whenever the respective (sub)event
|
||||||
has no end date set.
|
has no end date set.
|
||||||
|
├ event_location string Location of the (sub)event this line was created for as it
|
||||||
|
was set during invoice creation. Can be ``null`` for all invoice
|
||||||
|
lines created before this was introduced as well as for lines in
|
||||||
|
an event series not created by a product (e.g. shipping or
|
||||||
|
cancellation fees) as well as whenever the respective (sub)event
|
||||||
|
has no location set.
|
||||||
├ attendee_name string Attendee name at time of invoice creation. Can be ``null`` if no
|
├ attendee_name string Attendee name at time of invoice creation. Can be ``null`` if no
|
||||||
name was set or if names are configured to not be added to invoices.
|
name was set or if names are configured to not be added to invoices.
|
||||||
├ gross_value money (string) Price including taxes
|
├ gross_value money (string) Price including taxes
|
||||||
@@ -110,6 +122,14 @@ internal_reference string Customer's refe
|
|||||||
|
|
||||||
The attributes ``fee_type`` and ``fee_internal_type`` have been added.
|
The attributes ``fee_type`` and ``fee_internal_type`` have been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.1
|
||||||
|
|
||||||
|
The attribute ``lines.event_location`` has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.6
|
||||||
|
|
||||||
|
The attribute ``lines.subevent`` has been added.
|
||||||
|
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
@@ -175,10 +195,12 @@ Endpoints
|
|||||||
"description": "Budget Ticket",
|
"description": "Budget Ticket",
|
||||||
"item": 1234,
|
"item": 1234,
|
||||||
"variation": 245,
|
"variation": 245,
|
||||||
|
"subevent": null,
|
||||||
"fee_type": null,
|
"fee_type": null,
|
||||||
"fee_internal_type": null,
|
"fee_internal_type": null,
|
||||||
"event_date_from": "2017-12-27T10:00:00Z",
|
"event_date_from": "2017-12-27T10:00:00Z",
|
||||||
"event_date_to": null,
|
"event_date_to": null,
|
||||||
|
"event_location": "Heidelberg",
|
||||||
"attendee_name": null,
|
"attendee_name": null,
|
||||||
"gross_value": "23.00",
|
"gross_value": "23.00",
|
||||||
"tax_value": "0.00",
|
"tax_value": "0.00",
|
||||||
@@ -263,10 +285,12 @@ Endpoints
|
|||||||
"description": "Budget Ticket",
|
"description": "Budget Ticket",
|
||||||
"item": 1234,
|
"item": 1234,
|
||||||
"variation": 245,
|
"variation": 245,
|
||||||
|
"subevent": null,
|
||||||
"fee_type": null,
|
"fee_type": null,
|
||||||
"fee_internal_type": null,
|
"fee_internal_type": null,
|
||||||
"event_date_from": "2017-12-27T10:00:00Z",
|
"event_date_from": "2017-12-27T10:00:00Z",
|
||||||
"event_date_to": null,
|
"event_date_to": null,
|
||||||
|
"event_location": "Heidelberg",
|
||||||
"attendee_name": null,
|
"attendee_name": null,
|
||||||
"gross_value": "23.00",
|
"gross_value": "23.00",
|
||||||
"tax_value": "0.00",
|
"tax_value": "0.00",
|
||||||
|
|||||||
@@ -24,8 +24,25 @@ active boolean If ``false``, t
|
|||||||
description multi-lingual string A public description of the variation. May contain
|
description multi-lingual string A public description of the variation. May contain
|
||||||
Markdown syntax or can be ``null``.
|
Markdown syntax or can be ``null``.
|
||||||
position integer An integer, used for sorting
|
position integer An integer, used for sorting
|
||||||
|
require_approval boolean If ``true``, orders with this variation will need to be
|
||||||
|
approved by the event organizer before they can be
|
||||||
|
paid.
|
||||||
require_membership boolean If ``true``, booking this variation requires an active membership.
|
require_membership boolean If ``true``, booking this variation requires an active membership.
|
||||||
|
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
|
||||||
|
be hidden from users without a valid membership.
|
||||||
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
|
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
|
||||||
|
sales_channels list of strings Sales channels this variation is available on, such as
|
||||||
|
``"web"`` or ``"resellers"``. Defaults to all existing sales channels.
|
||||||
|
The item-level list takes precedence, i.e. a sales
|
||||||
|
channel needs to be on both lists for the item to be
|
||||||
|
available.
|
||||||
|
available_from datetime The first date time at which this variation can be bought
|
||||||
|
(or ``null``).
|
||||||
|
available_until datetime The last date time at which this variation can be bought
|
||||||
|
(or ``null``).
|
||||||
|
hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
|
||||||
|
redemption process, but not in the normal shop
|
||||||
|
frontend.
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
@@ -62,8 +79,14 @@ Endpoints
|
|||||||
"en": "S"
|
"en": "S"
|
||||||
},
|
},
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Test2"
|
"en": "Test2"
|
||||||
},
|
},
|
||||||
@@ -78,7 +101,9 @@ Endpoints
|
|||||||
"en": "L"
|
"en": "L"
|
||||||
},
|
},
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
"description": {},
|
"description": {},
|
||||||
"position": 1,
|
"position": 1,
|
||||||
@@ -127,8 +152,14 @@ Endpoints
|
|||||||
"price": "10.00",
|
"price": "10.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 0
|
"position": 0
|
||||||
}
|
}
|
||||||
@@ -158,8 +189,14 @@ Endpoints
|
|||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
"default_price": "10.00",
|
"default_price": "10.00",
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 0
|
"position": 0
|
||||||
}
|
}
|
||||||
@@ -179,8 +216,14 @@ Endpoints
|
|||||||
"price": "10.00",
|
"price": "10.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 0
|
"position": 0
|
||||||
}
|
}
|
||||||
@@ -231,8 +274,14 @@ Endpoints
|
|||||||
"price": "10.00",
|
"price": "10.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"active": false,
|
"active": false,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 1
|
"position": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ require_approval boolean If ``true``, or
|
|||||||
paid.
|
paid.
|
||||||
require_bundling boolean If ``true``, this item is only available as part of bundles.
|
require_bundling boolean If ``true``, this item is only available as part of bundles.
|
||||||
require_membership boolean If ``true``, booking this item requires an active membership.
|
require_membership boolean If ``true``, booking this item requires an active membership.
|
||||||
|
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this product will
|
||||||
|
be hidden from users without a valid membership.
|
||||||
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
|
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
|
||||||
grant_membership_type integer If set to the internal ID of a membership type, purchasing this item will
|
grant_membership_type integer If set to the internal ID of a membership type, purchasing this item will
|
||||||
create a membership of the given type.
|
create a membership of the given type.
|
||||||
@@ -105,8 +107,22 @@ variations list of objects A list with one
|
|||||||
├ active boolean If ``false``, this variation will not be sold or shown.
|
├ active boolean If ``false``, this variation will not be sold or shown.
|
||||||
├ description multi-lingual string A public description of the variation. May contain
|
├ description multi-lingual string A public description of the variation. May contain
|
||||||
├ require_membership boolean If ``true``, booking this variation requires an active membership.
|
├ require_membership boolean If ``true``, booking this variation requires an active membership.
|
||||||
|
├ require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
|
||||||
|
be hidden from users without a valid membership.
|
||||||
├ require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
|
├ require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
|
||||||
Markdown syntax or can be ``null``.
|
Markdown syntax or can be ``null``.
|
||||||
|
├ sales_channels list of strings Sales channels this variation is available on, such as
|
||||||
|
``"web"`` or ``"resellers"``. Defaults to all existing sales channels.
|
||||||
|
The item-level list takes precedence, i.e. a sales
|
||||||
|
channel needs to be on both lists for the item to be
|
||||||
|
available.
|
||||||
|
├ available_from datetime The first date time at which this variation can be bought
|
||||||
|
(or ``null``).
|
||||||
|
├ available_until datetime The last date time at which this variation can be bought
|
||||||
|
(or ``null``).
|
||||||
|
├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
|
||||||
|
redemption process, but not in the normal shop
|
||||||
|
frontend.
|
||||||
└ position integer An integer, used for sorting
|
└ position integer An integer, used for sorting
|
||||||
addons list of objects Definition of add-ons that can be chosen for this item.
|
addons list of objects Definition of add-ons that can be chosen for this item.
|
||||||
Only writable during creation,
|
Only writable during creation,
|
||||||
@@ -143,6 +159,10 @@ meta_data object Values set for
|
|||||||
The attributes ``require_membership``, ``require_membership_types``, ``grant_membership_type``, ``grant_membership_duration_like_event``,
|
The attributes ``require_membership``, ``require_membership_types``, ``grant_membership_type``, ``grant_membership_duration_like_event``,
|
||||||
``grant_membership_duration_days`` and ``grant_membership_duration_months`` have been added.
|
``grant_membership_duration_days`` and ``grant_membership_duration_months`` have been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.4
|
||||||
|
|
||||||
|
The attributes ``require_membership_hidden`` attribute has been added.
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
-----
|
-----
|
||||||
|
|
||||||
@@ -230,6 +250,10 @@ Endpoints
|
|||||||
"active": true,
|
"active": true,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 0
|
"position": 0
|
||||||
},
|
},
|
||||||
@@ -241,6 +265,10 @@ Endpoints
|
|||||||
"active": true,
|
"active": true,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 1
|
"position": 1
|
||||||
}
|
}
|
||||||
@@ -337,6 +365,10 @@ Endpoints
|
|||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
"description": null,
|
"description": null,
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"position": 0
|
"position": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -347,6 +379,10 @@ Endpoints
|
|||||||
"active": true,
|
"active": true,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 1
|
"position": 1
|
||||||
}
|
}
|
||||||
@@ -422,6 +458,10 @@ Endpoints
|
|||||||
"active": true,
|
"active": true,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 0
|
"position": 0
|
||||||
},
|
},
|
||||||
@@ -433,6 +473,10 @@ Endpoints
|
|||||||
"active": true,
|
"active": true,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 1
|
"position": 1
|
||||||
}
|
}
|
||||||
@@ -497,6 +541,10 @@ Endpoints
|
|||||||
"active": true,
|
"active": true,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 0
|
"position": 0
|
||||||
},
|
},
|
||||||
@@ -508,6 +556,10 @@ Endpoints
|
|||||||
"active": true,
|
"active": true,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 1
|
"position": 1
|
||||||
}
|
}
|
||||||
@@ -603,6 +655,10 @@ Endpoints
|
|||||||
"active": true,
|
"active": true,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 0
|
"position": 0
|
||||||
},
|
},
|
||||||
@@ -614,6 +670,10 @@ Endpoints
|
|||||||
"active": true,
|
"active": true,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
"sales_channels": ["web"],
|
||||||
|
"available_from": null,
|
||||||
|
"available_until": null,
|
||||||
|
"hide_without_voucher": false,
|
||||||
"description": null,
|
"description": null,
|
||||||
"position": 1
|
"position": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,14 @@ last_modified datetime Last modificati
|
|||||||
|
|
||||||
The ``custom_followup_at`` attribute has been added.
|
The ``custom_followup_at`` attribute has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.4
|
||||||
|
|
||||||
|
The ``item`` and ``variation`` query parameters have been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.6
|
||||||
|
|
||||||
|
The ``subevent`` query parameters has been added.
|
||||||
|
|
||||||
|
|
||||||
.. _order-position-resource:
|
.. _order-position-resource:
|
||||||
|
|
||||||
@@ -415,6 +423,8 @@ List of all orders
|
|||||||
:query string code: Only return orders that match the given order code
|
:query string code: Only return orders that match the given order code
|
||||||
:query string status: Only return orders in the given order status (see above)
|
:query string status: Only return orders in the given order status (see above)
|
||||||
:query string search: Only return orders matching a given search query
|
:query string search: Only return orders matching a given search query
|
||||||
|
:query integer item: Only return orders with a position that contains this item ID. *Warning:* Result will also include orders if they contain mixed items, and it will even return orders where the item is only contained in a canceled position.
|
||||||
|
:query integer variation: Only return orders with a position that contains this variation ID. *Warning:* Result will also include orders if they contain mixed items and variations, and it will even return orders where the variation is only contained in a canceled position.
|
||||||
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
|
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
|
||||||
:query boolean require_approval: If set to ``true`` or ``false``, only categories with this value for the field
|
:query boolean require_approval: If set to ``true`` or ``false``, only categories with this value for the field
|
||||||
``require_approval`` will be returned.
|
``require_approval`` will be returned.
|
||||||
@@ -427,6 +437,7 @@ List of all orders
|
|||||||
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
|
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
|
||||||
you will not notice it using this method.
|
you will not notice it using this method.
|
||||||
:query datetime created_since: Only return orders that have been created since the given date.
|
:query datetime created_since: Only return orders that have been created since the given date.
|
||||||
|
:query integer subevent: Only return orders with a position that contains this subevent ID. *Warning:* Result will also include orders if they contain mixed subevents, and it will even return orders where the subevent is only contained in a canceled position.
|
||||||
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive after, and it considers the **end** of the subevent (or its start, if the end is not set).
|
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive after, and it considers the **end** of the subevent (or its start, if the end is not set).
|
||||||
:query datetime subevent_before: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive before, and it considers the **start** of the subevent.
|
:query datetime subevent_before: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive before, and it considers the **start** of the subevent.
|
||||||
:query string exclude: Exclude a field from the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times.
|
:query string exclude: Exclude a field from the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times.
|
||||||
|
|||||||
281
doc/api/resources/sendmail_rules.rst
Normal file
281
doc/api/resources/sendmail_rules.rst
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
Automated email rules
|
||||||
|
=====================
|
||||||
|
|
||||||
|
Resource description
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Automated email rules that specify emails that the system will send automatically at a specific point in time, e.g.
|
||||||
|
the day of the event.
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
Field Type Description
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
id integer Internal ID of the rule
|
||||||
|
enabled boolean If ``false``, the rule is ignored
|
||||||
|
subject multi-lingual string The subject of the email
|
||||||
|
template multi-lingual string The body of the email
|
||||||
|
all_products boolean If ``true``, the email is sent to buyers of all products
|
||||||
|
limit_products list of integers List of product IDs, if ``all_products`` is not set
|
||||||
|
include_pending boolean If ``true``, the email is sent to pending orders. If ``false``,
|
||||||
|
only paid orders are considered.
|
||||||
|
date_is_absolute boolean If ``true``, the email is set at a specific point in time.
|
||||||
|
send_date datetime If ``date_is_absolute`` is set: Date and time to send the email.
|
||||||
|
send_offset_days integer If ``date_is_absolute`` is not set, this is the number of days
|
||||||
|
before/after the email is sent.
|
||||||
|
send_offset_time time If ``date_is_absolute`` is not set, this is the time of day the
|
||||||
|
email is sent on the day specified by ``send_offset_days``.
|
||||||
|
offset_to_event_end boolean If ``true``, ``send_offset_days`` is relative to the event end
|
||||||
|
date. Otherwise it is relative to the event start date.
|
||||||
|
offset_is_after boolean If ``true``, ``send_offset_days`` is the number of days **after**
|
||||||
|
the event start or end date. Otherwise it is the number of days
|
||||||
|
**before**.
|
||||||
|
send_to string Can be ``"orders"`` if the email should be sent to customers
|
||||||
|
(one email per order),
|
||||||
|
``"attendees"`` if the email should be sent to every attendee,
|
||||||
|
or ``"both"``.
|
||||||
|
date. Otherwise it is relative to the event start date.
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
---------
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/
|
||||||
|
|
||||||
|
Returns a list of all rules configured for an event.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/ 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,
|
||||||
|
"enabled": true,
|
||||||
|
"subject": {"en": "See you tomorrow!"},
|
||||||
|
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||||
|
"all_products": true,
|
||||||
|
"limit_products": [],
|
||||||
|
"include_pending": false,
|
||||||
|
"send_date": null,
|
||||||
|
"send_offset_days": 1,
|
||||||
|
"send_offset_time": "18:00",
|
||||||
|
"date_is_absolute": false,
|
||||||
|
"offset_to_event_end": false,
|
||||||
|
"offset_is_after": false,
|
||||||
|
"send_to": "orders"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:query page: The page number in case of a multi-page result set, default is 1
|
||||||
|
:param organizer: The ``slug`` field of a valid organizer
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/(id)/
|
||||||
|
|
||||||
|
Returns information on one rule, identified by its ID.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/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,
|
||||||
|
"enabled": true,
|
||||||
|
"subject": {"en": "See you tomorrow!"},
|
||||||
|
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||||
|
"all_products": true,
|
||||||
|
"limit_products": [],
|
||||||
|
"include_pending": false,
|
||||||
|
"send_date": null,
|
||||||
|
"send_offset_days": 1,
|
||||||
|
"send_offset_time": "18:00",
|
||||||
|
"date_is_absolute": false,
|
||||||
|
"offset_to_event_end": false,
|
||||||
|
"offset_is_after": false,
|
||||||
|
"send_to": "orders"
|
||||||
|
}
|
||||||
|
|
||||||
|
: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 rule to fetch
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to view it.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/
|
||||||
|
|
||||||
|
Create a new rule.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 166
|
||||||
|
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"subject": {"en": "See you tomorrow!"},
|
||||||
|
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||||
|
"all_products": true,
|
||||||
|
"limit_products": [],
|
||||||
|
"include_pending": false,
|
||||||
|
"send_date": null,
|
||||||
|
"send_offset_days": 1,
|
||||||
|
"send_offset_time": "18:00",
|
||||||
|
"date_is_absolute": false,
|
||||||
|
"offset_to_event_end": false,
|
||||||
|
"offset_is_after": false,
|
||||||
|
"send_to": "orders"
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 201 Created
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"enabled": true,
|
||||||
|
"subject": {"en": "See you tomorrow!"},
|
||||||
|
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||||
|
"all_products": true,
|
||||||
|
"limit_products": [],
|
||||||
|
"include_pending": false,
|
||||||
|
"send_date": null,
|
||||||
|
"send_offset_days": 1,
|
||||||
|
"send_offset_time": "18:00",
|
||||||
|
"date_is_absolute": false,
|
||||||
|
"offset_to_event_end": false,
|
||||||
|
"offset_is_after": false,
|
||||||
|
"send_to": "orders"
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to create a rule for
|
||||||
|
:param event: The ``slug`` field of the event to create a rule for
|
||||||
|
:statuscode 201: no error
|
||||||
|
:statuscode 400: The rule 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 rules.
|
||||||
|
|
||||||
|
|
||||||
|
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/(id)/
|
||||||
|
|
||||||
|
Update a rule. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||||
|
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||||
|
want to change.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
PATCH /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/1/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 34
|
||||||
|
|
||||||
|
{
|
||||||
|
"enabled": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: text/javascript
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"enabled": false,
|
||||||
|
"subject": {"en": "See you tomorrow!"},
|
||||||
|
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||||
|
"all_products": true,
|
||||||
|
"limit_products": [],
|
||||||
|
"include_pending": false,
|
||||||
|
"send_date": null,
|
||||||
|
"send_offset_days": 1,
|
||||||
|
"send_offset_time": "18:00",
|
||||||
|
"date_is_absolute": false,
|
||||||
|
"offset_to_event_end": false,
|
||||||
|
"offset_is_after": false,
|
||||||
|
"send_to": "orders"
|
||||||
|
}
|
||||||
|
|
||||||
|
: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 rule to modify
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: The rule could not be modified due to invalid submitted data.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it.
|
||||||
|
|
||||||
|
|
||||||
|
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/(id)/
|
||||||
|
|
||||||
|
Delete a rule.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
DELETE /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/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 rule to delete
|
||||||
|
:statuscode 204: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this rule cannot be deleted since it is currently in use.
|
||||||
@@ -16,15 +16,22 @@ Field Type Description
|
|||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
id integer Internal ID of the tax rule
|
id integer Internal ID of the tax rule
|
||||||
name multi-lingual string The tax rules' name
|
name multi-lingual string The tax rules' name
|
||||||
|
internal_name string An optional name that is only used in the backend
|
||||||
rate decimal (string) Tax rate in percent
|
rate decimal (string) Tax rate in percent
|
||||||
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
|
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
|
||||||
the specified product price
|
the specified product price
|
||||||
eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
|
eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
|
||||||
home_country string Merchant country (required for reverse charge), can be
|
home_country string Merchant country (required for reverse charge), can be
|
||||||
``null`` or empty string
|
``null`` or empty string
|
||||||
|
keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom
|
||||||
|
rules keep the gross price constant (default is ``false``)
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
.. versionchanged:: 4.6
|
||||||
|
|
||||||
|
The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added.
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
|
|
||||||
@@ -56,9 +63,11 @@ Endpoints
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "VAT"},
|
"name": {"en": "VAT"},
|
||||||
|
"internal_name": "VAT",
|
||||||
"rate": "19.00",
|
"rate": "19.00",
|
||||||
"price_includes_tax": true,
|
"price_includes_tax": true,
|
||||||
"eu_reverse_charge": false,
|
"eu_reverse_charge": false,
|
||||||
|
"keep_gross_if_rate_changes": false,
|
||||||
"home_country": "DE"
|
"home_country": "DE"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -94,9 +103,11 @@ Endpoints
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "VAT"},
|
"name": {"en": "VAT"},
|
||||||
|
"internal_name": "VAT",
|
||||||
"rate": "19.00",
|
"rate": "19.00",
|
||||||
"price_includes_tax": true,
|
"price_includes_tax": true,
|
||||||
"eu_reverse_charge": false,
|
"eu_reverse_charge": false,
|
||||||
|
"keep_gross_if_rate_changes": false,
|
||||||
"home_country": "DE"
|
"home_country": "DE"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,9 +151,11 @@ Endpoints
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "VAT"},
|
"name": {"en": "VAT"},
|
||||||
|
"internal_name": "VAT",
|
||||||
"rate": "19.00",
|
"rate": "19.00",
|
||||||
"price_includes_tax": true,
|
"price_includes_tax": true,
|
||||||
"eu_reverse_charge": false,
|
"eu_reverse_charge": false,
|
||||||
|
"keep_gross_if_rate_changes": false,
|
||||||
"home_country": "DE"
|
"home_country": "DE"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +198,11 @@ Endpoints
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "VAT"},
|
"name": {"en": "VAT"},
|
||||||
|
"internal_name": "VAT",
|
||||||
"rate": "20.00",
|
"rate": "20.00",
|
||||||
"price_includes_tax": true,
|
"price_includes_tax": true,
|
||||||
"eu_reverse_charge": false,
|
"eu_reverse_charge": false,
|
||||||
|
"keep_gross_if_rate_changes": false,
|
||||||
"home_country": "DE"
|
"home_country": "DE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
doc/development/algorithms/checkin.rst
Normal file
28
doc/development/algorithms/checkin.rst
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
.. spelling: libpretixsync
|
||||||
|
|
||||||
|
Check-in algorithms
|
||||||
|
===================
|
||||||
|
|
||||||
|
When a ticket is scanned at the entrance or exit of an event, we follow a series of steps to determine whether
|
||||||
|
the check-in is allowed or not. To understand some of the terms in the following diagrams, you should also check
|
||||||
|
out the documentation of the :ref:`ticket redemption API endpoint <rest-checkin-redeem>`.
|
||||||
|
|
||||||
|
Server-side
|
||||||
|
-----------
|
||||||
|
|
||||||
|
The following diagram shows the series of checks executed on the server when a ticket is redeemed through the API.
|
||||||
|
Some simplifications have been made, for example the deduplication mechanism based on the ``nonce`` parameter
|
||||||
|
to prevent re-uploads of the same scan is not shown.
|
||||||
|
|
||||||
|
.. image:: /images/checkin_online.png
|
||||||
|
|
||||||
|
Client-side
|
||||||
|
-----------
|
||||||
|
|
||||||
|
The process of verifying tickets offline is a little different. There are two different approaches,
|
||||||
|
depending on whether we have information about all tickets in the local database. The following diagram shows
|
||||||
|
the algorithm as currently implemented in recent versions of `libpretixsync`_.
|
||||||
|
|
||||||
|
.. image:: /images/checkin_offline.png
|
||||||
|
|
||||||
|
.. _libpretixsync: https://github.com/pretix/libpretixsync
|
||||||
13
doc/development/algorithms/index.rst
Normal file
13
doc/development/algorithms/index.rst
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
Algorithms
|
||||||
|
==========
|
||||||
|
|
||||||
|
The business logic inside pretix is full of complex algorithms making decisions based on all the hundreds of settings
|
||||||
|
and input parameters available. Some of them are documented here as graphs, either because fully understanding them is very important
|
||||||
|
when working on features close to them, or because they also need to be re-implemented by client-side components like our
|
||||||
|
ticket scanning apps and we want to ensure the implementations are as similar as possible to avoid confusion.
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
|
||||||
|
checkin
|
||||||
|
layouts
|
||||||
15
doc/development/algorithms/layouts.rst
Normal file
15
doc/development/algorithms/layouts.rst
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.. spelling: pretixPOS
|
||||||
|
|
||||||
|
Ticket layout
|
||||||
|
=============
|
||||||
|
|
||||||
|
When a ticket is exported to PDF, the system needs to decide which of multiple PDF layouts to use. The
|
||||||
|
following diagram shows the steps of the decision, showing both the implementation in pretix itself as
|
||||||
|
well as the implementation in `pretixPOS`_.
|
||||||
|
|
||||||
|
The process can be influenced by plugins, which is demonstrated with the example of the shipping plugin.
|
||||||
|
|
||||||
|
.. image:: /images/ticket_layouts.png
|
||||||
|
|
||||||
|
|
||||||
|
.. _pretixPOS: https://pretix.eu/about/en/pos
|
||||||
119
doc/development/api/cookieconsent.rst
Normal file
119
doc/development/api/cookieconsent.rst
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
.. highlight:: python
|
||||||
|
:linenothreshold: 5
|
||||||
|
|
||||||
|
.. _`cookieconsent`:
|
||||||
|
|
||||||
|
Handling cookie consent
|
||||||
|
=======================
|
||||||
|
|
||||||
|
pretix includes an optional feature to handle cookie consent explicitly to comply with EU regulations.
|
||||||
|
If your plugin sets non-essential cookies or includes a third-party service that does so, you should
|
||||||
|
integrate with this feature.
|
||||||
|
|
||||||
|
Server-side integration
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
First, you need to declare that you are using non-essential cookies by responding to the following
|
||||||
|
signal:
|
||||||
|
|
||||||
|
.. automodule:: pretix.presale.signals
|
||||||
|
:members: register_cookie_providers
|
||||||
|
|
||||||
|
You are expected to return a list of ``CookieProvider`` objects instantiated from the following class:
|
||||||
|
|
||||||
|
.. class:: pretix.presale.cookies.CookieProvider
|
||||||
|
|
||||||
|
.. py:attribute:: CookieProvider.identifier
|
||||||
|
|
||||||
|
A short and unique identifier used to distinguish this cookie provider form others (required).
|
||||||
|
|
||||||
|
.. py:attribute:: CookieProvider.provider_name
|
||||||
|
|
||||||
|
A human-readable name of the entity of feature responsible for setting the cookie (required).
|
||||||
|
|
||||||
|
.. py:attribute:: CookieProvider.usage_classes
|
||||||
|
|
||||||
|
A list of enum values from the ``pretix.presale.cookies.UsageClass`` enumeration class, such as
|
||||||
|
``UsageClass.ANALYTICS``, ``UsageClass.MARKETING``, or ``UsageClass.SOCIAL`` (required).
|
||||||
|
|
||||||
|
.. py:attribute:: CookieProvider.privacy_url
|
||||||
|
|
||||||
|
A link to a privacy policy (optional).
|
||||||
|
|
||||||
|
Here is an example of such a receiver:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
@receiver(register_cookie_providers)
|
||||||
|
def recv_cookie_providers(sender, request, **kwargs):
|
||||||
|
return [
|
||||||
|
CookieProvider(
|
||||||
|
identifier='google_analytics',
|
||||||
|
provider_name='Google Analytics',
|
||||||
|
usage_classes=[UsageClass.ANALYTICS],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
JavaScript-side integration
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
The server-side integration only causes the cookie provider to show up in the cookie dialog. You still
|
||||||
|
need to care about actually enforcing the consent state.
|
||||||
|
|
||||||
|
You can access the consent state through the ``window.pretix.cookie_consent`` variable. Whenever the
|
||||||
|
value changes, a ``pretix:cookie-consent:change`` event is fired on the ``document`` object.
|
||||||
|
|
||||||
|
The variable will generally have one of the following states:
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
================================================================ =====================================================
|
||||||
|
State Interpretation
|
||||||
|
================================================================ =====================================================
|
||||||
|
``pretix === undefined || pretix.cookie_consent === undefined`` Your JavaScript has loaded before the cookie consent
|
||||||
|
script. Wait for the event to be fired, then try again,
|
||||||
|
do not yet set a cookie.
|
||||||
|
``pretix.cookie_consent === null`` The cookie consent mechanism has not been enabled. This
|
||||||
|
usually means that you can set cookies however you like.
|
||||||
|
``pretix.cookie_consent[identifier] === undefined`` The cookie consent mechanism is loaded, but has no data
|
||||||
|
on your cookie yet, wait for the event to be fired, do not
|
||||||
|
yet set a cookie.
|
||||||
|
``pretix.cookie_consent[identifier] === true`` The user has consented to your cookie.
|
||||||
|
``pretix.cookie_consent[identifier] === false`` The user has actively rejected your cookie.
|
||||||
|
================================================================ =====================================================
|
||||||
|
|
||||||
|
If you are integrating e.g. a tracking provider with native cookie consent support such
|
||||||
|
as Facebook's Pixel, you can integrate it like this:
|
||||||
|
|
||||||
|
.. code-block:: javascript
|
||||||
|
|
||||||
|
var consent = (window.pretix || {}).cookie_consent;
|
||||||
|
if (consent !== null && !(consent || {}).facebook) {
|
||||||
|
fbq('consent', 'revoke');
|
||||||
|
}
|
||||||
|
fbq('init', ...);
|
||||||
|
document.addEventListener('pretix:cookie-consent:change', function (e) {
|
||||||
|
fbq('consent', (e.detail || {}).facebook ? 'grant' : 'revoke');
|
||||||
|
})
|
||||||
|
|
||||||
|
If you have a JavaScript function that you only want to load if consent for a specific ``identifier``
|
||||||
|
is given, you can wrap it like this:
|
||||||
|
|
||||||
|
.. code-block:: javascript
|
||||||
|
|
||||||
|
var consent_identifier = "youridentifier";
|
||||||
|
var consent = (window.pretix || {}).cookie_consent;
|
||||||
|
if (consent === null || (consent || {})[consent_identifier] === true) {
|
||||||
|
// Cookie consent tool is either disabled or consent is given
|
||||||
|
addScriptElement(src);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either cookie consent tool has not loaded yet or consent is not given
|
||||||
|
document.addEventListener('pretix:cookie-consent:change', function onChange(e) {
|
||||||
|
var consent = e.detail || {};
|
||||||
|
if (consent === null || consent[consent_identifier] === true) {
|
||||||
|
addScriptElement(src);
|
||||||
|
document.removeEventListener('pretix:cookie-consent:change', onChange);
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -17,6 +17,7 @@ Contents:
|
|||||||
shredder
|
shredder
|
||||||
import
|
import
|
||||||
customview
|
customview
|
||||||
|
cookieconsent
|
||||||
auth
|
auth
|
||||||
general
|
general
|
||||||
quality
|
quality
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ The provider class
|
|||||||
|
|
||||||
.. autoattribute:: public_name
|
.. autoattribute:: public_name
|
||||||
|
|
||||||
|
.. autoattribute:: confirm_button_name
|
||||||
|
|
||||||
.. autoattribute:: is_enabled
|
.. autoattribute:: is_enabled
|
||||||
|
|
||||||
.. autoattribute:: priority
|
.. autoattribute:: priority
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
.. spelling:: Rebase rebasing
|
||||||
|
|
||||||
Coding style and quality
|
Coding style and quality
|
||||||
========================
|
========================
|
||||||
|
|
||||||
|
Code
|
||||||
|
----
|
||||||
|
|
||||||
* Basically, we want all python code to follow the `PEP 8`_ standard. There are a few exceptions where
|
* Basically, we want all python code to follow the `PEP 8`_ standard. There are a few exceptions where
|
||||||
we see things differently or just aren't that strict. The ``setup.cfg`` file in the project's source
|
we see things differently or just aren't that strict. The ``setup.cfg`` file in the project's source
|
||||||
folder contains definitions that allow `flake8`_ to check for violations automatically. See :ref:`checksandtests`
|
folder contains definitions that allow `flake8`_ to check for violations automatically. See :ref:`checksandtests`
|
||||||
@@ -20,8 +25,62 @@ Coding style and quality
|
|||||||
test suite are in the style of Python's unit test module. If you extend those files, you might continue in this style,
|
test suite are in the style of Python's unit test module. If you extend those files, you might continue in this style,
|
||||||
but please use ``pytest`` style for any new test files.
|
but please use ``pytest`` style for any new test files.
|
||||||
|
|
||||||
* Please keep the first line of your commit messages short. When referencing an issue, please phrase it like
|
Commits and Pull Requests
|
||||||
``Fix #123 -- Problems with order creation`` or ``Refs #123 -- Fix this part of that bug``.
|
-------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Most commits should start as pull requests, therefore this applies to the titles of pull requests as well since
|
||||||
|
the pull request title will become the commit message on merge. We prefer merging with GitHub's "Squash and merge"
|
||||||
|
feature if the PR contains multiple commits that do not carry value to keep. If there is value in keeping the
|
||||||
|
individual commits, we use "Rebase and merge" instead. Merge commits should be avoided.
|
||||||
|
|
||||||
|
* The commit message should start with a single subject line and can optionally be followed by a commit message body.
|
||||||
|
|
||||||
|
* The subject line should be the shortest possible representation of what the commit changes. Someone who reviewed
|
||||||
|
the commit should able to immediately remember the commit in a couple of weeks based on the subject line and tell
|
||||||
|
it apart from other commits.
|
||||||
|
|
||||||
|
* If there's additional useful information that we should keep, such as reasoning behind the commit, you can
|
||||||
|
add a longer body, separated from the first line by a blank line.
|
||||||
|
|
||||||
|
* The body should explain **what** you changed and more importantly **why** you changed it. There's no need to iterate
|
||||||
|
**how** you changed something.
|
||||||
|
|
||||||
|
* The subject line should be capitalized ("Add new feature" instead of "add new feature") and should not end with a period
|
||||||
|
("Add new feature" instead of "Add new feature.")
|
||||||
|
|
||||||
|
* The subject line should be written in imperative mood, as if you were giving a command what the computer should do if the
|
||||||
|
commit is applied. This is how generated commit messages by git itself are already written ("Merge branch …", "Revert …")
|
||||||
|
and makes for short and consistent messages.
|
||||||
|
|
||||||
|
* Good: "Fix typo in template"
|
||||||
|
* Good: "Add Chinese translation"
|
||||||
|
* Good: "Remove deprecated method"
|
||||||
|
* Good: "Bump version to 4.4.0"
|
||||||
|
* Bad: "Fixed bug with …"
|
||||||
|
* Bad: "Fixes bug with …"
|
||||||
|
* Bad: "Fixing bug …"
|
||||||
|
|
||||||
|
* If all changes in your commit are in context of a single feature or e.g. a bundled plugin, it makes sense to prefix the
|
||||||
|
subject line with the name of that feature. Examples:
|
||||||
|
|
||||||
|
* "API: Add support for PATCH on customers"
|
||||||
|
* "Docs: Add chapter on alpaca feeding"
|
||||||
|
* "Stripe: Fix duplicate payments"
|
||||||
|
* "Order change form: Fix incorrect validation"
|
||||||
|
|
||||||
|
* If your commit references a GitHub issue that is fully resolved by your commit, start your subject line with the issue
|
||||||
|
ID in the form of "Fix #1234 -- Crash in order list". In this case, you can omit the verb "Fix" at the beginning of the
|
||||||
|
second part of the message to avoid repetition of the word "fix". If your commit only partially resolves the issue, use
|
||||||
|
"Refs #1234 -- Crash in order list" instead.
|
||||||
|
|
||||||
|
* Applies to pretix employees only: If your commit references a sentry issue, please put it in parentheses at the end
|
||||||
|
of the subject line or inside the body ("Fix crash in order list (PRETIXEU-ABC)"). If your commit references a support
|
||||||
|
ticket, please put it in parentheses at the end of the subject line with a "Z#" prefix ("Fix crash in order list (Z#12345)").
|
||||||
|
|
||||||
|
* If your PR was open for a while and might cause conflicts on merge, please prefer rebasing it (``git rebase -i master``)
|
||||||
|
over merging ``master`` into your branch unless it is prohibitively complicated.
|
||||||
|
|
||||||
|
|
||||||
.. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/
|
.. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ Carts and Orders
|
|||||||
.. autoclass:: pretix.base.models.OrderRefund
|
.. autoclass:: pretix.base.models.OrderRefund
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: pretix.base.models.Transaction
|
||||||
|
:members:
|
||||||
|
|
||||||
.. autoclass:: pretix.base.models.CartPosition
|
.. autoclass:: pretix.base.models.CartPosition
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Developer documentation
|
|||||||
setup
|
setup
|
||||||
contribution/index
|
contribution/index
|
||||||
implementation/index
|
implementation/index
|
||||||
translation/index
|
algorithms/index
|
||||||
api/index
|
api/index
|
||||||
structure
|
structure
|
||||||
|
translation/index
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Your should install the following on your system:
|
|||||||
* ``libssl`` (Debian package: ``libssl-dev``)
|
* ``libssl`` (Debian package: ``libssl-dev``)
|
||||||
* ``libxml2`` (Debian package ``libxml2-dev``)
|
* ``libxml2`` (Debian package ``libxml2-dev``)
|
||||||
* ``libxslt`` (Debian package ``libxslt1-dev``)
|
* ``libxslt`` (Debian package ``libxslt1-dev``)
|
||||||
* ``libenchant1c2a`` (Debian package ``libenchant1c2a``)
|
* ``libenchant-2-2`` (Debian package ``libenchant-2-2``)
|
||||||
* ``msgfmt`` (Debian package ``gettext``)
|
* ``msgfmt`` (Debian package ``gettext``)
|
||||||
* ``git``
|
* ``git``
|
||||||
|
|
||||||
@@ -51,7 +51,12 @@ the dependencies might fail::
|
|||||||
|
|
||||||
Working with the code
|
Working with the code
|
||||||
---------------------
|
---------------------
|
||||||
The first thing you need are all the main application's dependencies::
|
If you do not have a recent installation of ``nodejs``, install it now::
|
||||||
|
|
||||||
|
curl -sL https://deb.nodesource.com/setup_17.x | sudo -E bash -
|
||||||
|
sudo apt install nodejs
|
||||||
|
|
||||||
|
To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix::
|
||||||
|
|
||||||
cd src/
|
cd src/
|
||||||
pip3 install -e ".[dev]"
|
pip3 install -e ".[dev]"
|
||||||
|
|||||||
BIN
doc/images/checkin_offline.png
Normal file
BIN
doc/images/checkin_offline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
146
doc/images/checkin_offline.puml
Normal file
146
doc/images/checkin_offline.puml
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
@startuml
|
||||||
|
|
||||||
|
|
||||||
|
partition "data-based check" {
|
||||||
|
"Check based on local database" --> "Is the order in status PAID or PENDING\nand is the position not canceled?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error CANCELED"
|
||||||
|
else
|
||||||
|
-down->[yes] "Is the product part of the check-in list?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error PRODUCT"
|
||||||
|
else
|
||||||
|
-down->[yes] "Is the subevent part of the check-in list?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error INVALID"
|
||||||
|
note bottom: TODO\ninconsistent\nwith online\ncheck
|
||||||
|
else
|
||||||
|
-down->[yes] "Is the order in status PAID?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Does the check-in list include pending orders?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error UNPAID "
|
||||||
|
else
|
||||||
|
-down->[yes] "Is ignore_unpaid set?\n(Has the operator confirmed\nthe checkin?)"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error UNPAID "
|
||||||
|
else
|
||||||
|
-down->[yes] "Is this an entry or exit?"
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
else
|
||||||
|
-down->[yes] "Is this an entry or exit?"
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
"Is this an entry or exit?" --> if "" then
|
||||||
|
-right->[entry] Evaluate custom logic (rules)
|
||||||
|
--> if "" then
|
||||||
|
-right->[error] "Return error RULES"
|
||||||
|
else
|
||||||
|
-down->[ok] "Are all required questions answered?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error INCOMPLETE"
|
||||||
|
else
|
||||||
|
-down->[yes] "Does the check-in list allow multi-entry?"
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
else
|
||||||
|
-->[exit] "Return OK "
|
||||||
|
endif
|
||||||
|
|
||||||
|
"Does the check-in list allow multi-entry?" --> if "" then
|
||||||
|
-right->[yes] "Return OK"
|
||||||
|
else
|
||||||
|
-down->[no] "Is this the first checkin\nfor this ticket on this list?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[yes] "Return OK"
|
||||||
|
else
|
||||||
|
-down->[no] "Are all previous checkins\nfor this ticket on this list exits?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[yes] "Return OK"
|
||||||
|
else
|
||||||
|
-down->[no] "Does the check-in list\n allow entry after exit\nand is the last checkin\nan exit?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[yes] "Return OK"
|
||||||
|
else
|
||||||
|
-down->[no] "Return error ALREADY_REDEEMED"
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
}
|
||||||
|
|
||||||
|
partition "dataless check" {
|
||||||
|
"Check based on secret content" --> "Does the secret decode with\nany supported scheme\nand has a valid signature?"
|
||||||
|
|
||||||
|
--> if "" then
|
||||||
|
-down->[yes] "Is the ticket secret on the revocation list?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[yes] "Return error REVOKED"
|
||||||
|
else
|
||||||
|
-down->[no] "Is the product part of the check-in list? "
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error PRODUCT "
|
||||||
|
else
|
||||||
|
-down->[yes] "Is the subevent part of the check-in list? "
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error INVALID "
|
||||||
|
note bottom: TODO\ninconsistent\nwith online\ncheck
|
||||||
|
else
|
||||||
|
--> "Is this an entry or exit? "
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
else
|
||||||
|
-right>[no] "Return error INVALID "
|
||||||
|
endif
|
||||||
|
|
||||||
|
"Is this an entry or exit? " --> if "" then
|
||||||
|
-right->[entry] "Evaluate custom logic (rules) "
|
||||||
|
--> if "" then
|
||||||
|
-right->[error] "Return error RULES "
|
||||||
|
else
|
||||||
|
-down->[ok] "Are all required questions answered? "
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error INCOMPLETE "
|
||||||
|
else
|
||||||
|
-down->[yes] "Does the check-in list allow multi-entry? "
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
else
|
||||||
|
-->[exit] " Return OK "
|
||||||
|
endif
|
||||||
|
|
||||||
|
"Does the check-in list allow multi-entry? " --> if "" then
|
||||||
|
-right->[yes] " Return OK "
|
||||||
|
else
|
||||||
|
-down->[no] "Are any locally queued checkins for\nthis ticket of this list known?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] " Return OK "
|
||||||
|
else
|
||||||
|
-down->[yes] "Are all locally queued checkins\nfor this ticket on this list exits? "
|
||||||
|
--> if "" then
|
||||||
|
-right->[yes] " Return OK "
|
||||||
|
else
|
||||||
|
-down->[no] "Does the check-in list\n allow entry after exit\nand is the last locally\nqueued checkin\nan exit? "
|
||||||
|
--> if "" then
|
||||||
|
-right->[yes] " Return OK "
|
||||||
|
else
|
||||||
|
-down->[no] "Return error ALREADY_REDEEMED "
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
}
|
||||||
|
|
||||||
|
(*) --> "Check if order position with\nscanned ticket secret exists"
|
||||||
|
--> if "" then
|
||||||
|
-down->[yes] "Check based on local database"
|
||||||
|
else
|
||||||
|
-->[no] "Check based on secret content"
|
||||||
|
endif
|
||||||
|
|
||||||
|
@enduml
|
||||||
BIN
doc/images/checkin_online.png
Normal file
BIN
doc/images/checkin_online.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
92
doc/images/checkin_online.puml
Normal file
92
doc/images/checkin_online.puml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
@startuml
|
||||||
|
|
||||||
|
(*) --> "Check if order position with\nscanned ticket secret exists"
|
||||||
|
--> if "" then
|
||||||
|
-down->[yes] ===CHECK===
|
||||||
|
else
|
||||||
|
-->[no] "Check if secret exists\nin revocation list"
|
||||||
|
--> if "" then
|
||||||
|
--> "Is this a forced upload?"
|
||||||
|
--> if "" then
|
||||||
|
-->[yes] ===CHECK===
|
||||||
|
else
|
||||||
|
-right->[no] "Return error REVOKED"
|
||||||
|
endif
|
||||||
|
else
|
||||||
|
-right->[no] "Return error INVALID"
|
||||||
|
endif
|
||||||
|
|
||||||
|
endif
|
||||||
|
|
||||||
|
|
||||||
|
===CHECK=== -down-> "Is the order in status PAID or PENDING\nand is the position not canceled?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error CANCELED"
|
||||||
|
else
|
||||||
|
-down->[yes] "Is the product part of the check-in list?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error PRODUCT"
|
||||||
|
else
|
||||||
|
-down->[yes] "Is the subevent part of the check-in list?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error PRODUCT "
|
||||||
|
else
|
||||||
|
-down->[yes] "Is the order in status PAID\nor is this a forced upload?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Does the check-in list include pending orders?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error UNPAID "
|
||||||
|
else
|
||||||
|
-down->[yes] "Is ignore_unpaid set?\n(Has the operator confirmed\nthe checkin?)"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no] "Return error UNPAID "
|
||||||
|
else
|
||||||
|
-down->[yes] "Is this an entry or exit?\nIs the upload forced?"
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
else
|
||||||
|
-down->[yes] "Is this an entry or exit?\nIs the upload forced?"
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
"Is this an entry or exit?\nIs the upload forced?" --> if "" then
|
||||||
|
-right->[entry && not force] Evaluate custom logic (rules)
|
||||||
|
--> if "" then
|
||||||
|
-right->[error] "Return error RULES"
|
||||||
|
else
|
||||||
|
-down->[ok] "Are all required questions answered?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[no && questions_supported] "Return error INCOMPLETE"
|
||||||
|
else
|
||||||
|
-down->[yes || not questions_supported] "Does the check-in list allow multi-entry?"
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
else
|
||||||
|
-->[exit || force=true] "Return OK "
|
||||||
|
endif
|
||||||
|
|
||||||
|
"Does the check-in list allow multi-entry?" --> if "" then
|
||||||
|
-right->[yes] "Return OK"
|
||||||
|
else
|
||||||
|
-down->[no] "Is this the first checkin\nfor this ticket on this list?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[yes] "Return OK"
|
||||||
|
else
|
||||||
|
-down->[no] "Are all previous checkins\nfor this ticket on this list exits?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[yes] "Return OK"
|
||||||
|
else
|
||||||
|
-down->[no] "Does the check-in list\n allow entry after exit\nand is the last checkin\nan exit?"
|
||||||
|
--> if "" then
|
||||||
|
-right->[yes] "Return OK"
|
||||||
|
else
|
||||||
|
-down->[no] "Return error ALREADY_REDEEMED"
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
|
||||||
|
@enduml
|
||||||
BIN
doc/images/ticket_layouts.png
Normal file
BIN
doc/images/ticket_layouts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
52
doc/images/ticket_layouts.puml
Normal file
52
doc/images/ticket_layouts.puml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
@startuml
|
||||||
|
|
||||||
|
(*) --> "Which implementation?"
|
||||||
|
--> if "" then
|
||||||
|
-down->[pretixPOS] "Check for TicketLayoutItem with\nsales_channel=pretixpos [libpretixsync]"
|
||||||
|
--> if "" then
|
||||||
|
--> (*)
|
||||||
|
else
|
||||||
|
-->[not found] "Check for TicketLayoutItem with\nsales_channel=web [libpretixsync]"
|
||||||
|
--> if "" then
|
||||||
|
--> (*)
|
||||||
|
else
|
||||||
|
-->[not found] "Use event default [libpretixsync]"
|
||||||
|
--> (*)
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
else
|
||||||
|
-right->[pretix] "Check for TicketLayoutItem with\nsales_channel=order.sales_channel"
|
||||||
|
--> if "" then
|
||||||
|
-right-> "Run override_layout plugin signal on result"
|
||||||
|
else
|
||||||
|
-down->[not found] "Check for TicketLayoutItem with\nsales_channel=web"
|
||||||
|
--> if "" then
|
||||||
|
--> "Run override_layout plugin signal on result"
|
||||||
|
else
|
||||||
|
-->[not found] "Use event default"
|
||||||
|
--> "Run override_layout plugin signal on result"
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
|
||||||
|
"Run override_layout plugin signal on result" -> (*)
|
||||||
|
|
||||||
|
|
||||||
|
partition pretix_shipping {
|
||||||
|
"Run override_layout plugin signal on result" --> "Check for ShippingLayoutItem with\nmethod=order.shipping_method"
|
||||||
|
--> if "" then
|
||||||
|
--> (*)
|
||||||
|
else
|
||||||
|
-down->[not found] "Check for ShippingMethod.layout"
|
||||||
|
--> if "" then
|
||||||
|
--> (*)
|
||||||
|
else
|
||||||
|
-down->[not found] "Keep original layout"
|
||||||
|
--> (*)
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@enduml
|
||||||
64
doc/plugins/certificates.rst
Normal file
64
doc/plugins/certificates.rst
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
Certificates of attendance
|
||||||
|
==========================
|
||||||
|
|
||||||
|
The certificates plugin provides a HTTP API that allows you to download the certificate for a specific attendee.
|
||||||
|
|
||||||
|
|
||||||
|
Certificate download
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/certificate/
|
||||||
|
|
||||||
|
Downloads the certificate for one order position, identified by its internal ID. Download is a two-step
|
||||||
|
process. You will always get a :http:statuscode:`303` response with a ``Location`` header to a different
|
||||||
|
URL. In the background, our server starts preparing the PDF file.
|
||||||
|
|
||||||
|
If you then do a ``GET`` to the URL you were given, you will either receive a :http:statuscode:`409` response
|
||||||
|
indicating to retry after a few seconds, or a :http:statuscode:`200` response with the PDF file.
|
||||||
|
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/certificate/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 303 See Other
|
||||||
|
Location: /api/v1/organizers/democon/events/3vjrh/orderpositions/426/certificate/?result=1f550651-ae7b-4911-a76c-2be8f348aaa5
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/certificate/?result=1f550651-ae7b-4911-a76c-2be8f348aaa5 HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/pdf
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:param id: The ``id`` field of the order position to fetch
|
||||||
|
:statuscode 200: File ready for download
|
||||||
|
:statuscode 303: Processing started
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource
|
||||||
|
**or** downloads are not available for this order position at this time. The response content will
|
||||||
|
contain more details.
|
||||||
|
:statuscode 404: The requested order position or download provider does not exist.
|
||||||
|
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||||
|
seconds.
|
||||||
@@ -15,5 +15,6 @@ If you want to **create** a plugin, please go to the
|
|||||||
ticketoutputpdf
|
ticketoutputpdf
|
||||||
badges
|
badges
|
||||||
campaigns
|
campaigns
|
||||||
|
certificates
|
||||||
digital
|
digital
|
||||||
webinar
|
webinar
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ bic
|
|||||||
BIC
|
BIC
|
||||||
boolean
|
boolean
|
||||||
booleans
|
booleans
|
||||||
|
bugfix
|
||||||
cancelled
|
cancelled
|
||||||
casted
|
casted
|
||||||
Ceph
|
Ceph
|
||||||
@@ -77,6 +78,7 @@ mixin
|
|||||||
mixins
|
mixins
|
||||||
multi
|
multi
|
||||||
multidomain
|
multidomain
|
||||||
|
multiplicator
|
||||||
namespace
|
namespace
|
||||||
namespaced
|
namespaced
|
||||||
namespaces
|
namespaces
|
||||||
|
|||||||
@@ -203,4 +203,4 @@ Then, please contact support@pretix.eu and we will enable DKIM for your domain o
|
|||||||
|
|
||||||
|
|
||||||
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
|
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
|
||||||
.. _SPF specification: http://www.openspf.org/SPF_Record_Syntax
|
.. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ Embeddable Widget
|
|||||||
=================
|
=================
|
||||||
|
|
||||||
If you want to show your ticket shop on your event website or blog, you can use our JavaScript widget. This way,
|
If you want to show your ticket shop on your event website or blog, you can use our JavaScript widget. This way,
|
||||||
users will not need to leave your site to buy their ticket in most cases. The widget will still open a new tab
|
users will not need to leave your site to buy their ticket in most cases.
|
||||||
for the checkout if the user is on a mobile device.
|
|
||||||
|
|
||||||
To obtain the correct HTML code for embedding your event into your website, we recommend that you go to the "Widget"
|
To obtain the correct HTML code for embedding your event into your website, we recommend that you go to the "Widget"
|
||||||
tab of your event's settings. You can specify some optional settings there (for example the language of the widget)
|
tab of your event's settings. You can specify some optional settings there (for example the language of the widget)
|
||||||
@@ -310,6 +309,10 @@ Currently, the following attributes are understood by pretix itself:
|
|||||||
always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely
|
always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely
|
||||||
on this for authentication.
|
on this for authentication.
|
||||||
|
|
||||||
|
* If ``data-consent="…"`` is given, the cookie consent mechanism will be initialized with consent for the given cookie
|
||||||
|
providers. All other providers will be disabled, no consent dialog will be shown. This is useful if you already
|
||||||
|
asked the user for consent and don't want them to be asked again. Example: ``data-consent="facebook,google_analytics"``
|
||||||
|
|
||||||
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
|
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
|
||||||
Hosted or pretix Enterprise are active, you can pass the following fields:
|
Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||||
|
|
||||||
|
|||||||
@@ -34,5 +34,7 @@ git push
|
|||||||
# Unlock Weblate
|
# Unlock Weblate
|
||||||
for c in $COMPONENTS; do
|
for c in $COMPONENTS; do
|
||||||
wlc unlock $c;
|
wlc unlock $c;
|
||||||
|
done
|
||||||
|
for c in $COMPONENTS; do
|
||||||
wlc pull $c;
|
wlc pull $c;
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -19,4 +19,4 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
__version__ = "4.1.0"
|
__version__ = "4.6.1"
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
|
||||||
identifier = 'pretixscan_online_kiosk'
|
identifier = 'pretixscan_online_kiosk'
|
||||||
verbose_name = _('pretixSCAN (kiosk mode, online only)')
|
verbose_name = _('pretixSCAN (kiosk mode, no order sync, no search)')
|
||||||
allowlist = (
|
allowlist = (
|
||||||
('GET', 'api-v1:version'),
|
('GET', 'api-v1:version'),
|
||||||
('GET', 'api-v1:device.eventselection'),
|
('GET', 'api-v1:device.eventselection'),
|
||||||
@@ -99,6 +99,36 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||||
|
identifier = 'pretixscan_online_noorders'
|
||||||
|
verbose_name = _('pretixSCAN (online only, no order sync)')
|
||||||
|
allowlist = (
|
||||||
|
('GET', 'api-v1:version'),
|
||||||
|
('GET', 'api-v1:device.eventselection'),
|
||||||
|
('POST', 'api-v1:device.update'),
|
||||||
|
('POST', 'api-v1:device.revoke'),
|
||||||
|
('POST', 'api-v1:device.roll'),
|
||||||
|
('GET', 'api-v1:event-list'),
|
||||||
|
('GET', 'api-v1:event-detail'),
|
||||||
|
('GET', 'api-v1:subevent-list'),
|
||||||
|
('GET', 'api-v1:subevent-detail'),
|
||||||
|
('GET', 'api-v1:itemcategory-list'),
|
||||||
|
('GET', 'api-v1:item-list'),
|
||||||
|
('GET', 'api-v1:question-list'),
|
||||||
|
('GET', 'api-v1:badgelayout-list'),
|
||||||
|
('GET', 'api-v1:badgeitem-list'),
|
||||||
|
('GET', 'api-v1:checkinlist-list'),
|
||||||
|
('GET', 'api-v1:checkinlist-status'),
|
||||||
|
('POST', 'api-v1:checkinlist-failed_checkins'),
|
||||||
|
('GET', 'api-v1:checkinlistpos-list'),
|
||||||
|
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||||
|
('GET', 'api-v1:revokedsecrets-list'),
|
||||||
|
('GET', 'api-v1:orderposition-pdf_image'),
|
||||||
|
('GET', 'api-v1:event.settings'),
|
||||||
|
('POST', 'api-v1:upload'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PretixPosSecurityProfile(AllowListSecurityProfile):
|
class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||||
identifier = 'pretixpos'
|
identifier = 'pretixpos'
|
||||||
verbose_name = _('pretixPOS')
|
verbose_name = _('pretixPOS')
|
||||||
@@ -133,6 +163,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
|||||||
('POST', 'api-v1:orderrefund-list'),
|
('POST', 'api-v1:orderrefund-list'),
|
||||||
('POST', 'api-v1:orderrefund-done'),
|
('POST', 'api-v1:orderrefund-done'),
|
||||||
('POST', 'api-v1:cartposition-list'),
|
('POST', 'api-v1:cartposition-list'),
|
||||||
|
('POST', 'api-v1:cartposition-bulk-create'),
|
||||||
('GET', 'api-v1:checkinlist-list'),
|
('GET', 'api-v1:checkinlist-list'),
|
||||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||||
('POST', 'plugins:pretix_posbackend:order.posprintlog'),
|
('POST', 'plugins:pretix_posbackend:order.posprintlog'),
|
||||||
@@ -160,6 +191,7 @@ DEVICE_SECURITY_PROFILES = {
|
|||||||
FullAccessSecurityProfile,
|
FullAccessSecurityProfile,
|
||||||
PretixScanSecurityProfile,
|
PretixScanSecurityProfile,
|
||||||
PretixScanNoSyncSecurityProfile,
|
PretixScanNoSyncSecurityProfile,
|
||||||
|
PretixScanNoSyncNoSearchSecurityProfile,
|
||||||
PretixPosSecurityProfile,
|
PretixPosSecurityProfile,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/pretix/api/migrations/0006_alter_webhook_target_url.py
Normal file
18
src/pretix/api/migrations/0006_alter_webhook_target_url.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.2 on 2021-07-05 07:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixapi', '0005_auto_20191028_1541'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='webhook',
|
||||||
|
name='target_url',
|
||||||
|
field=models.URLField(max_length=255),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-09-15 11:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixapi', '0006_alter_webhook_target_url'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='webhookcall',
|
||||||
|
name='target_url',
|
||||||
|
field=models.URLField(max_length=255),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -95,7 +95,7 @@ class OAuthRefreshToken(AbstractRefreshToken):
|
|||||||
class WebHook(models.Model):
|
class WebHook(models.Model):
|
||||||
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
|
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
|
||||||
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
|
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
|
||||||
target_url = models.URLField(verbose_name=_("Target URL"))
|
target_url = models.URLField(verbose_name=_("Target URL"), max_length=255)
|
||||||
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
|
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)
|
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ class WebHookEventListener(models.Model):
|
|||||||
class WebHookCall(models.Model):
|
class WebHookCall(models.Model):
|
||||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
|
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
|
||||||
datetime = models.DateTimeField(auto_now_add=True)
|
datetime = models.DateTimeField(auto_now_add=True)
|
||||||
target_url = models.URLField()
|
target_url = models.URLField(max_length=255)
|
||||||
action_type = models.CharField(max_length=255)
|
action_type = models.CharField(max_length=255)
|
||||||
is_retry = models.BooleanField(default=False)
|
is_retry = models.BooleanField(default=False)
|
||||||
execution_time = models.FloatField(null=True)
|
execution_time = models.FloatField(null=True)
|
||||||
|
|||||||
27
src/pretix/api/pagination.py
Normal file
27
src/pretix/api/pagination.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
|
||||||
|
|
||||||
|
class Pagination(PageNumberPagination):
|
||||||
|
page_size_query_param = 'page_size'
|
||||||
|
max_page_size = 50
|
||||||
@@ -73,53 +73,61 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
|||||||
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
|
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'))
|
||||||
new_quotas = (validated_data.get('variation').quotas.filter(subevent=validated_data.get('subevent'))
|
if validated_data.get('variation')
|
||||||
if validated_data.get('variation')
|
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
|
||||||
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
|
if len(new_quotas) == 0:
|
||||||
if len(new_quotas) == 0:
|
raise ValidationError(
|
||||||
|
gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||||
|
str(validated_data.get('item'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for quota in new_quotas:
|
||||||
|
avail = quota.availability(_cache=self.context['quota_cache'])
|
||||||
|
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
gettext_lazy('There is not enough quota available on quota "{}" to perform '
|
||||||
str(validated_data.get('item'))
|
'the operation.').format(
|
||||||
|
quota.name
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
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(
|
|
||||||
gettext_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
|
|
||||||
}
|
|
||||||
|
|
||||||
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
|
for quota in new_quotas:
|
||||||
if validated_data.get('seat'):
|
oldsize = self.context['quota_cache'][quota.pk][1]
|
||||||
if not seated:
|
newsize = oldsize - 1 if oldsize is not None else None
|
||||||
raise ValidationError('The specified product does not allow to choose a seat.')
|
self.context['quota_cache'][quota.pk] = (
|
||||||
try:
|
Quota.AVAILABILITY_OK if newsize is None or newsize > 0 else Quota.AVAILABILITY_GONE,
|
||||||
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
|
newsize
|
||||||
except Seat.DoesNotExist:
|
)
|
||||||
raise ValidationError('The specified seat does not exist.')
|
|
||||||
except Seat.MultipleObjectsReturned:
|
|
||||||
raise ValidationError('The specified seat ID is not unique.')
|
|
||||||
else:
|
|
||||||
validated_data['seat'] = seat
|
|
||||||
if not seat.is_available(
|
|
||||||
sales_channel=validated_data.get('sales_channel', 'web'),
|
|
||||||
distance_ignore_cart_id=validated_data['cart_id'],
|
|
||||||
):
|
|
||||||
raise ValidationError(gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
|
|
||||||
elif seated:
|
|
||||||
raise ValidationError('The specified product requires to choose a seat.')
|
|
||||||
|
|
||||||
validated_data.pop('sales_channel')
|
attendee_name = validated_data.pop('attendee_name', '')
|
||||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
if attendee_name and not validated_data.get('attendee_name_parts'):
|
||||||
|
validated_data['attendee_name_parts'] = {
|
||||||
|
'_legacy': attendee_name
|
||||||
|
}
|
||||||
|
|
||||||
|
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
|
||||||
|
if validated_data.get('seat'):
|
||||||
|
if not seated:
|
||||||
|
raise ValidationError('The specified product does not allow to choose a seat.')
|
||||||
|
try:
|
||||||
|
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
|
||||||
|
except Seat.DoesNotExist:
|
||||||
|
raise ValidationError('The specified seat does not exist.')
|
||||||
|
except Seat.MultipleObjectsReturned:
|
||||||
|
raise ValidationError('The specified seat ID is not unique.')
|
||||||
|
else:
|
||||||
|
validated_data['seat'] = seat
|
||||||
|
if not seat.is_available(
|
||||||
|
sales_channel=validated_data.get('sales_channel', 'web'),
|
||||||
|
distance_ignore_cart_id=validated_data['cart_id'],
|
||||||
|
):
|
||||||
|
raise ValidationError(gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
|
||||||
|
elif seated:
|
||||||
|
raise ValidationError('The specified product requires to choose a seat.')
|
||||||
|
|
||||||
|
validated_data.pop('sales_channel')
|
||||||
|
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||||
|
|
||||||
for answ_data in answers_data:
|
for answ_data in answers_data:
|
||||||
options = answ_data.pop('options')
|
options = answ_data.pop('options')
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ from pretix.base.models.items import SubEventItem, SubEventItemVariation
|
|||||||
from pretix.base.services.seating import (
|
from pretix.base.services.seating import (
|
||||||
SeatProtected, generate_seats, validate_plan_change,
|
SeatProtected, generate_seats, validate_plan_change,
|
||||||
)
|
)
|
||||||
from pretix.base.settings import validate_event_settings
|
from pretix.base.settings import LazyI18nStringList, validate_event_settings
|
||||||
from pretix.base.signals import api_event_settings_fields
|
from pretix.base.signals import api_event_settings_fields
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -637,7 +637,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
|||||||
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TaxRule
|
model = TaxRule
|
||||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
|
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes')
|
||||||
|
|
||||||
|
|
||||||
class EventSettingsSerializer(SettingsSerializer):
|
class EventSettingsSerializer(SettingsSerializer):
|
||||||
@@ -704,6 +704,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'payment_term_accept_late',
|
'payment_term_accept_late',
|
||||||
'payment_explanation',
|
'payment_explanation',
|
||||||
'payment_pending_hidden',
|
'payment_pending_hidden',
|
||||||
|
'mail_days_order_expire_warning',
|
||||||
'ticket_download',
|
'ticket_download',
|
||||||
'ticket_download_date',
|
'ticket_download_date',
|
||||||
'ticket_download_addons',
|
'ticket_download_addons',
|
||||||
@@ -712,7 +713,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'ticket_download_require_validated_email',
|
'ticket_download_require_validated_email',
|
||||||
'ticket_secret_length',
|
'ticket_secret_length',
|
||||||
'mail_prefix',
|
'mail_prefix',
|
||||||
'mail_from',
|
|
||||||
'mail_from_name',
|
'mail_from_name',
|
||||||
'mail_attach_ical',
|
'mail_attach_ical',
|
||||||
'mail_attach_tickets',
|
'mail_attach_tickets',
|
||||||
@@ -733,6 +733,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'invoice_numbers_prefix_cancellations',
|
'invoice_numbers_prefix_cancellations',
|
||||||
'invoice_numbers_counter_length',
|
'invoice_numbers_counter_length',
|
||||||
'invoice_attendee_name',
|
'invoice_attendee_name',
|
||||||
|
'invoice_event_location',
|
||||||
'invoice_include_expire_date',
|
'invoice_include_expire_date',
|
||||||
'invoice_address_explanation_text',
|
'invoice_address_explanation_text',
|
||||||
'invoice_email_attachment',
|
'invoice_email_attachment',
|
||||||
@@ -762,6 +763,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'cancel_allow_user_paid_refund_as_giftcard',
|
'cancel_allow_user_paid_refund_as_giftcard',
|
||||||
'cancel_allow_user_paid_require_approval',
|
'cancel_allow_user_paid_require_approval',
|
||||||
'change_allow_user_variation',
|
'change_allow_user_variation',
|
||||||
|
'change_allow_user_addons',
|
||||||
'change_allow_user_until',
|
'change_allow_user_until',
|
||||||
'change_allow_user_price',
|
'change_allow_user_price',
|
||||||
'primary_color',
|
'primary_color',
|
||||||
@@ -789,6 +791,10 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
data = super().validate(data)
|
data = super().validate(data)
|
||||||
settings_dict = self.instance.freeze()
|
settings_dict = self.instance.freeze()
|
||||||
settings_dict.update(data)
|
settings_dict.update(data)
|
||||||
|
|
||||||
|
if data.get('confirm_texts') is not None:
|
||||||
|
data['confirm_texts'] = LazyI18nStringList(data['confirm_texts'])
|
||||||
|
|
||||||
validate_event_settings(self.event, settings_dict)
|
validate_event_settings(self.event, settings_dict)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,10 @@
|
|||||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
import os.path
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
@@ -57,8 +58,10 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ItemVariation
|
model = ItemVariation
|
||||||
fields = ('id', 'value', 'active', 'description',
|
fields = ('id', 'value', 'active', 'description',
|
||||||
'position', 'default_price', 'price', 'original_price',
|
'position', 'default_price', 'price', 'original_price', 'require_approval',
|
||||||
'require_membership', 'require_membership_types',)
|
'require_membership', 'require_membership_types',
|
||||||
|
'require_membership_hidden', 'available_from', 'available_until',
|
||||||
|
'sales_channels', 'hide_without_voucher',)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -72,8 +75,10 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ItemVariation
|
model = ItemVariation
|
||||||
fields = ('id', 'value', 'active', 'description',
|
fields = ('id', 'value', 'active', 'description',
|
||||||
'position', 'default_price', 'price', 'original_price',
|
'position', 'default_price', 'price', 'original_price', 'require_approval',
|
||||||
'require_membership', 'require_membership_types',)
|
'require_membership', 'require_membership_types',
|
||||||
|
'require_membership_hidden', 'available_from', 'available_until',
|
||||||
|
'sales_channels', 'hide_without_voucher',)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -161,7 +166,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
|||||||
meta_data = MetaDataField(required=False, source='*')
|
meta_data = MetaDataField(required=False, source='*')
|
||||||
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
||||||
'image/png', 'image/jpeg', 'image/gif'
|
'image/png', 'image/jpeg', 'image/gif'
|
||||||
), max_size=10 * 1024 * 1024)
|
), max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Item
|
model = Item
|
||||||
@@ -172,7 +177,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
|||||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
||||||
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data',
|
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data',
|
||||||
'require_membership', 'require_membership_types', 'grant_membership_type',
|
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
|
||||||
'grant_membership_duration_like_event', 'grant_membership_duration_days',
|
'grant_membership_duration_like_event', 'grant_membership_duration_days',
|
||||||
'grant_membership_duration_months')
|
'grant_membership_duration_months')
|
||||||
read_only_fields = ('has_variations',)
|
read_only_fields = ('has_variations',)
|
||||||
@@ -245,10 +250,13 @@ class ItemSerializer(I18nAwareModelSerializer):
|
|||||||
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
|
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
|
||||||
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
|
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
|
||||||
meta_data = validated_data.pop('meta_data', None)
|
meta_data = validated_data.pop('meta_data', None)
|
||||||
|
picture = validated_data.pop('picture', None)
|
||||||
item = Item.objects.create(**validated_data)
|
item = Item.objects.create(**validated_data)
|
||||||
|
if picture:
|
||||||
|
item.picture.save(os.path.basename(picture.name), picture)
|
||||||
|
|
||||||
for variation_data in variations_data:
|
for variation_data in variations_data:
|
||||||
require_membership_types = variation_data.pop('require_membership_types')
|
require_membership_types = variation_data.pop('require_membership_types', [])
|
||||||
v = ItemVariation.objects.create(item=item, **variation_data)
|
v = ItemVariation.objects.create(item=item, **variation_data)
|
||||||
if require_membership_types:
|
if require_membership_types:
|
||||||
v.require_membership_types.add(*require_membership_types)
|
v.require_membership_types.add(*require_membership_types)
|
||||||
@@ -269,7 +277,10 @@ class ItemSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
meta_data = validated_data.pop('meta_data', None)
|
meta_data = validated_data.pop('meta_data', None)
|
||||||
|
picture = validated_data.pop('picture', None)
|
||||||
item = super().update(instance, validated_data)
|
item = super().update(instance, validated_data)
|
||||||
|
if picture:
|
||||||
|
item.picture.save(os.path.basename(picture.name), picture)
|
||||||
|
|
||||||
# Meta data
|
# Meta data
|
||||||
if meta_data is not None:
|
if meta_data is not None:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from collections import Counter, defaultdict
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pycountry
|
import pycountry
|
||||||
|
from django.conf import settings
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
@@ -191,7 +192,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
|||||||
)
|
)
|
||||||
if cf.type not in allowed_types:
|
if cf.type not in allowed_types:
|
||||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||||
if cf.file.size > 10 * 1024 * 1024:
|
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||||
|
|
||||||
data['options'] = []
|
data['options'] = []
|
||||||
@@ -1403,6 +1404,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
state=OrderPayment.PAYMENT_STATE_CREATED
|
state=OrderPayment.PAYMENT_STATE_CREATED
|
||||||
)
|
)
|
||||||
|
|
||||||
|
order.create_transactions(is_new=True, fees=fees, positions=pos_map.values())
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
@@ -1424,9 +1426,9 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InvoiceLine
|
model = InvoiceLine
|
||||||
fields = ('position', 'description', 'item', 'variation', 'attendee_name', 'event_date_from',
|
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
|
||||||
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
|
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
|
||||||
'fee_internal_type')
|
'fee_internal_type', 'event_location')
|
||||||
|
|
||||||
|
|
||||||
class InvoiceSerializer(I18nAwareModelSerializer):
|
class InvoiceSerializer(I18nAwareModelSerializer):
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
|||||||
default_fields = [
|
default_fields = [
|
||||||
'customer_accounts',
|
'customer_accounts',
|
||||||
'customer_accounts_link_by_email',
|
'customer_accounts_link_by_email',
|
||||||
|
'invoice_regenerate_allowed',
|
||||||
'contact_mail',
|
'contact_mail',
|
||||||
'imprint_url',
|
'imprint_url',
|
||||||
'organizer_info_text',
|
'organizer_info_text',
|
||||||
@@ -294,7 +295,15 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
|||||||
'theme_color_background',
|
'theme_color_background',
|
||||||
'theme_round_borders',
|
'theme_round_borders',
|
||||||
'primary_font',
|
'primary_font',
|
||||||
'organizer_logo_image'
|
'organizer_logo_image_inherit',
|
||||||
|
'organizer_logo_image',
|
||||||
|
'privacy_url',
|
||||||
|
'cookie_consent',
|
||||||
|
'cookie_consent_dialog_title',
|
||||||
|
'cookie_consent_dialog_text',
|
||||||
|
'cookie_consent_dialog_text_secondary',
|
||||||
|
'cookie_consent_dialog_button_yes',
|
||||||
|
'cookie_consent_dialog_button_no',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -21,14 +21,18 @@
|
|||||||
#
|
#
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from rest_framework import status, viewsets
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.filters import OrderingFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.settings import api_settings
|
||||||
|
|
||||||
from pretix.api.serializers.cart import (
|
from pretix.api.serializers.cart import (
|
||||||
CartPositionCreateSerializer, CartPositionSerializer,
|
CartPositionCreateSerializer, CartPositionSerializer,
|
||||||
)
|
)
|
||||||
from pretix.base.models import CartPosition
|
from pretix.base.models import CartPosition
|
||||||
|
from pretix.base.services.locking import NoLockManager
|
||||||
|
|
||||||
|
|
||||||
class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
@@ -50,18 +54,61 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
|
|||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
ctx['event'] = self.request.event
|
ctx['event'] = self.request.event
|
||||||
|
ctx['quota_cache'] = {}
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
|
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
with transaction.atomic():
|
with transaction.atomic(), self.request.event.lock():
|
||||||
self.perform_create(serializer)
|
self.perform_create(serializer)
|
||||||
cp = serializer.instance
|
cp = serializer.instance
|
||||||
serializer = CartPositionSerializer(cp, context=serializer.context)
|
serializer = CartPositionSerializer(cp, context=serializer.context)
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['POST'])
|
||||||
|
def bulk_create(self, request, *args, **kwargs):
|
||||||
|
if not isinstance(request.data, list): # noqa
|
||||||
|
return Response({"error": "Please supply a list"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
ctx = self.get_serializer_context()
|
||||||
|
with transaction.atomic():
|
||||||
|
serializers = [
|
||||||
|
CartPositionCreateSerializer(data=d, context=ctx)
|
||||||
|
for d in request.data
|
||||||
|
]
|
||||||
|
|
||||||
|
lockfn = self.request.event.lock
|
||||||
|
if not any(s.is_valid(raise_exception=False) for s in serializers):
|
||||||
|
lockfn = NoLockManager
|
||||||
|
|
||||||
|
results = []
|
||||||
|
with lockfn():
|
||||||
|
for s in serializers:
|
||||||
|
if s.is_valid(raise_exception=False):
|
||||||
|
try:
|
||||||
|
cp = s.save()
|
||||||
|
except ValidationError as e:
|
||||||
|
results.append({
|
||||||
|
'success': False,
|
||||||
|
'data': None,
|
||||||
|
'errors': {api_settings.NON_FIELD_ERRORS_KEY: e.detail},
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
'success': True,
|
||||||
|
'data': CartPositionSerializer(cp, context=ctx).data,
|
||||||
|
'errors': None,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
'success': False,
|
||||||
|
'data': None,
|
||||||
|
'errors': s.errors,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({'results': results}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
import django_filters
|
import django_filters
|
||||||
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
@@ -32,6 +33,7 @@ from django.utils.functional import cached_property
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
from packaging.version import parse
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import DateTimeField
|
from rest_framework.fields import DateTimeField
|
||||||
@@ -421,13 +423,20 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
nonce=nonce,
|
nonce=nonce,
|
||||||
forced=force,
|
forced=force,
|
||||||
)
|
)
|
||||||
|
raw_barcode_for_checkin = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
|
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
|
||||||
if self.kwargs['pk'].isnumeric():
|
if self.kwargs['pk'].isnumeric():
|
||||||
op = queryset.get(Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
op = queryset.get(Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
||||||
else:
|
else:
|
||||||
op = queryset.get(secret=self.kwargs['pk'])
|
# In application/x-www-form-urlencoded, you can encodes space ' ' with '+' instead of '%20'.
|
||||||
|
# `id`, however, is part of a path where this technically is not allowed. Old versions of our
|
||||||
|
# scan apps still do it, so we try work around it!
|
||||||
|
try:
|
||||||
|
op = queryset.get(secret=self.kwargs['pk'])
|
||||||
|
except OrderPosition.DoesNotExist:
|
||||||
|
op = queryset.get(secret=self.kwargs['pk'].replace('+', ' '))
|
||||||
except OrderPosition.DoesNotExist:
|
except OrderPosition.DoesNotExist:
|
||||||
revoked_matches = list(self.request.event.revoked_secrets.filter(secret=self.kwargs['pk']))
|
revoked_matches = list(self.request.event.revoked_secrets.filter(secret=self.kwargs['pk']))
|
||||||
if len(revoked_matches) == 0:
|
if len(revoked_matches) == 0:
|
||||||
@@ -455,7 +464,41 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
error_reason=Checkin.REASON_INVALID,
|
error_reason=Checkin.REASON_INVALID,
|
||||||
**common_checkin_args,
|
**common_checkin_args,
|
||||||
)
|
)
|
||||||
raise Http404()
|
|
||||||
|
if force and isinstance(self.request.auth, Device):
|
||||||
|
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
|
||||||
|
# valid at the time but no longer exists at time of upload, the device would retry to
|
||||||
|
# upload the same scan over and over again. Since we can't update all devices quickly,
|
||||||
|
# here's a dirty workaround to make it stop.
|
||||||
|
try:
|
||||||
|
brand = self.request.auth.software_brand
|
||||||
|
ver = parse(self.request.auth.software_version)
|
||||||
|
legacy_mode = (
|
||||||
|
(brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or
|
||||||
|
(brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or
|
||||||
|
(brand == 'pretixSCAN' and ver < parse('1.11.2'))
|
||||||
|
)
|
||||||
|
if legacy_mode:
|
||||||
|
return Response({
|
||||||
|
'status': 'error',
|
||||||
|
'reason': Checkin.REASON_ALREADY_REDEEMED,
|
||||||
|
'reason_explanation': None,
|
||||||
|
'require_attention': False,
|
||||||
|
'__warning': 'Compatibility hack active due to detected old pretixSCAN version',
|
||||||
|
}, status=400)
|
||||||
|
except: # we don't care e.g. about invalid version numbers
|
||||||
|
pass
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'detail': 'Not found.', # for backwards compatibility
|
||||||
|
'status': 'error',
|
||||||
|
'reason': Checkin.REASON_INVALID,
|
||||||
|
'reason_explanation': None,
|
||||||
|
'require_attention': False,
|
||||||
|
}, status=404)
|
||||||
|
elif revoked_matches and force:
|
||||||
|
op = revoked_matches[0].position
|
||||||
|
raw_barcode_for_checkin = self.kwargs['pk']
|
||||||
else:
|
else:
|
||||||
op = revoked_matches[0].position
|
op = revoked_matches[0].position
|
||||||
op.order.log_action('pretix.event.checkin.revoked', data={
|
op.order.log_action('pretix.event.checkin.revoked', data={
|
||||||
@@ -506,7 +549,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
type=type,
|
type=type,
|
||||||
raw_barcode=None,
|
raw_barcode=raw_barcode_for_checkin,
|
||||||
|
from_revoked_secret=True,
|
||||||
)
|
)
|
||||||
except RequiredQuestionsError as e:
|
except RequiredQuestionsError as e:
|
||||||
return Response({
|
return Response({
|
||||||
@@ -566,7 +610,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
)
|
)
|
||||||
if cf.type not in allowed_types:
|
if cf.type not in allowed_types:
|
||||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||||
if cf.file.size > 10 * 1024 * 1024:
|
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||||
|
|
||||||
return cf.file
|
return cf.file
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class ExportersMixin:
|
|||||||
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
||||||
if cf.file:
|
if cf.file:
|
||||||
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
||||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
|
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
|
||||||
return resp
|
return resp
|
||||||
elif not settings.HAS_CELERY:
|
elif not settings.HAS_CELERY:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -132,7 +132,7 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
|||||||
def exporters(self):
|
def exporters(self):
|
||||||
exporters = []
|
exporters = []
|
||||||
responses = register_data_exporters.send(self.request.event)
|
responses = register_data_exporters.send(self.request.event)
|
||||||
for ex in sorted([response(self.request.event) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
|
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
|
||||||
ex._serializer = JobRunSerializer(exporter=ex)
|
ex._serializer = JobRunSerializer(exporter=ex)
|
||||||
exporters.append(ex)
|
exporters.append(ex)
|
||||||
return exporters
|
return exporters
|
||||||
@@ -151,7 +151,7 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
|||||||
organizer=self.request.organizer
|
organizer=self.request.organizer
|
||||||
)
|
)
|
||||||
responses = register_multievent_data_exporters.send(self.request.organizer)
|
responses = register_multievent_data_exporters.send(self.request.organizer)
|
||||||
for ex in sorted([response(events) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
|
for ex in sorted([response(events, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
|
||||||
ex._serializer = JobRunSerializer(exporter=ex, events=events)
|
ex._serializer = JobRunSerializer(exporter=ex, events=events)
|
||||||
exporters.append(ex)
|
exporters.append(ex)
|
||||||
return exporters
|
return exporters
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ with scopes_disabled():
|
|||||||
subevent_after = django_filters.IsoDateTimeFilter(method='subevent_after_qs')
|
subevent_after = django_filters.IsoDateTimeFilter(method='subevent_after_qs')
|
||||||
subevent_before = django_filters.IsoDateTimeFilter(method='subevent_before_qs')
|
subevent_before = django_filters.IsoDateTimeFilter(method='subevent_before_qs')
|
||||||
search = django_filters.CharFilter(method='search_qs')
|
search = django_filters.CharFilter(method='search_qs')
|
||||||
|
item = django_filters.CharFilter(field_name='all_positions', lookup_expr='item_id')
|
||||||
|
variation = django_filters.CharFilter(field_name='all_positions', lookup_expr='variation_id')
|
||||||
|
subevent = django_filters.CharFilter(field_name='all_positions', lookup_expr='subevent_id')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
@@ -214,7 +217,9 @@ class OrderViewSet(viewsets.ModelViewSet):
|
|||||||
'positions',
|
'positions',
|
||||||
opq.all().prefetch_related(
|
opq.all().prefetch_related(
|
||||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||||
'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
|
'item', 'variation',
|
||||||
|
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')),
|
||||||
|
'seat',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1451,8 +1456,14 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
inv = self.get_object()
|
inv = self.get_object()
|
||||||
if inv.canceled:
|
if inv.canceled:
|
||||||
raise ValidationError('The invoice has already been canceled.')
|
raise ValidationError('The invoice has already been canceled.')
|
||||||
|
if not inv.event.settings.invoice_regenerate_allowed:
|
||||||
|
raise PermissionDenied('Invoices may not be changed after they are created.')
|
||||||
elif inv.shredded:
|
elif inv.shredded:
|
||||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||||
|
elif inv.sent_to_organizer:
|
||||||
|
raise PermissionDenied('The invoice file has already been exported.')
|
||||||
|
elif now().astimezone(self.request.event.timezone).date() - inv.date > datetime.timedelta(days=1):
|
||||||
|
raise PermissionDenied('The invoice file is too old to be regenerated.')
|
||||||
else:
|
else:
|
||||||
inv = regenerate_invoice(inv)
|
inv = regenerate_invoice(inv)
|
||||||
inv.order.log_action(
|
inv.order.log_action(
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ def register_default_webhook_events(sender, **kwargs):
|
|||||||
),
|
),
|
||||||
ParametrizedEventWebhookEvent(
|
ParametrizedEventWebhookEvent(
|
||||||
'pretix.event.deleted',
|
'pretix.event.deleted',
|
||||||
_('Event details changed'),
|
_('Event deleted'),
|
||||||
),
|
),
|
||||||
ParametrizedSubEventWebhookEvent(
|
ParametrizedSubEventWebhookEvent(
|
||||||
'pretix.subevent.added',
|
'pretix.subevent.added',
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class PretixBaseConfig(AppConfig):
|
|||||||
from . import notifications # NOQA
|
from . import notifications # NOQA
|
||||||
from . import email # NOQA
|
from . import email # NOQA
|
||||||
from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||||
|
from .models import _transactions # NOQA
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -146,7 +146,8 @@ class NativeAuthBackend(BaseAuthBackend):
|
|||||||
d = OrderedDict([
|
d = OrderedDict([
|
||||||
('email', forms.EmailField(label=_("E-mail"), max_length=254,
|
('email', forms.EmailField(label=_("E-mail"), max_length=254,
|
||||||
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
|
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
|
||||||
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput)),
|
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput,
|
||||||
|
max_length=4096)),
|
||||||
])
|
])
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ class SalesChannel:
|
|||||||
"""
|
"""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def customer_accounts_supported(self) -> bool:
|
||||||
|
"""
|
||||||
|
If this property is ``True``, checkout will show the customer login step.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_all_sales_channels():
|
def get_all_sales_channels():
|
||||||
global _ALL_CHANNELS
|
global _ALL_CHANNELS
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from datetime import timedelta
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
from smtplib import SMTPResponseException
|
from smtplib import SMTPResponseException
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import css_inline
|
import css_inline
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -49,23 +50,23 @@ from pretix.base.templatetags.rich_text import markdown_compile_email
|
|||||||
|
|
||||||
logger = logging.getLogger('pretix.base.email')
|
logger = logging.getLogger('pretix.base.email')
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=EmailBackend)
|
||||||
|
|
||||||
class CustomSMTPBackend(EmailBackend):
|
|
||||||
|
|
||||||
def test(self, from_addr):
|
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
|
||||||
try:
|
try:
|
||||||
self.open()
|
backend.open()
|
||||||
self.connection.ehlo_or_helo_if_needed()
|
backend.connection.ehlo_or_helo_if_needed()
|
||||||
(code, resp) = self.connection.mail(from_addr, [])
|
(code, resp) = backend.connection.mail(from_addr, [])
|
||||||
if code != 250:
|
if code != 250:
|
||||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
logger.warning('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||||
raise SMTPResponseException(code, resp)
|
raise SMTPResponseException(code, resp)
|
||||||
(code, resp) = self.connection.rcpt('testdummy@pretix.eu')
|
(code, resp) = backend.connection.rcpt('testdummy@pretix.eu')
|
||||||
if (code != 250) and (code != 251):
|
if (code != 250) and (code != 251):
|
||||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
logger.warning('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||||
raise SMTPResponseException(code, resp)
|
raise SMTPResponseException(code, resp)
|
||||||
finally:
|
finally:
|
||||||
self.close()
|
backend.close()
|
||||||
|
|
||||||
|
|
||||||
class BaseHTMLMailRenderer:
|
class BaseHTMLMailRenderer:
|
||||||
@@ -462,6 +463,16 @@ def base_placeholders(sender, **kwargs):
|
|||||||
lambda waiting_list_entry, event: (waiting_list_entry.subevent or event).get_date_from_display(),
|
lambda waiting_list_entry, event: (waiting_list_entry.subevent or event).get_date_from_display(),
|
||||||
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
|
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
|
||||||
),
|
),
|
||||||
|
SimpleFunctionalMailTextPlaceholder(
|
||||||
|
'url_remove', ['waiting_list_entry', 'event'],
|
||||||
|
lambda waiting_list_entry, event: build_absolute_uri(
|
||||||
|
event, 'presale:event.waitinglist.remove'
|
||||||
|
) + '?voucher=' + waiting_list_entry.voucher.code,
|
||||||
|
lambda event: build_absolute_uri(
|
||||||
|
event,
|
||||||
|
'presale:event.waitinglist.remove',
|
||||||
|
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||||
|
),
|
||||||
SimpleFunctionalMailTextPlaceholder(
|
SimpleFunctionalMailTextPlaceholder(
|
||||||
'url', ['waiting_list_entry', 'event'],
|
'url', ['waiting_list_entry', 'event'],
|
||||||
lambda waiting_list_entry, event: build_absolute_uri(
|
lambda waiting_list_entry, event: build_absolute_uri(
|
||||||
@@ -529,6 +540,22 @@ def base_placeholders(sender, **kwargs):
|
|||||||
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
|
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
|
||||||
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
|
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
|
||||||
),
|
),
|
||||||
|
SimpleFunctionalMailTextPlaceholder(
|
||||||
|
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||||
|
'voucher_url_list', ['event', 'voucher_list'],
|
||||||
|
lambda event, voucher_list: ' \n'.join([
|
||||||
|
build_absolute_uri(
|
||||||
|
event, 'presale:event.redeem'
|
||||||
|
) + '?voucher=' + c
|
||||||
|
for c in voucher_list
|
||||||
|
]),
|
||||||
|
lambda event: ' \n'.join([
|
||||||
|
build_absolute_uri(
|
||||||
|
event, 'presale:event.redeem'
|
||||||
|
) + '?voucher=' + c
|
||||||
|
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
|
||||||
|
]),
|
||||||
|
),
|
||||||
SimpleFunctionalMailTextPlaceholder(
|
SimpleFunctionalMailTextPlaceholder(
|
||||||
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
|
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||||
'event': event.slug,
|
'event': event.slug,
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import re
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections import OrderedDict, namedtuple
|
from collections import OrderedDict, namedtuple
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -46,23 +45,13 @@ from django.conf import settings
|
|||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.utils.formats import localize
|
from django.utils.formats import localize
|
||||||
from django.utils.translation import gettext, gettext_lazy as _
|
from django.utils.translation import gettext, gettext_lazy as _
|
||||||
from openpyxl import Workbook
|
|
||||||
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES
|
|
||||||
|
|
||||||
from pretix.base.models import Event
|
from pretix.base.models import Event
|
||||||
|
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
|
||||||
|
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
|
||||||
|
)
|
||||||
|
|
||||||
|
__ = excel_safe # just so the compatbility import above is "used" and doesn't get removed by linter
|
||||||
def excel_safe(val):
|
|
||||||
if not isinstance(val, KNOWN_TYPES):
|
|
||||||
val = str(val)
|
|
||||||
|
|
||||||
if isinstance(val, bytes):
|
|
||||||
val = val.decode("utf-8", errors="ignore")
|
|
||||||
|
|
||||||
if isinstance(val, str):
|
|
||||||
val = re.sub(ILLEGAL_CHARACTERS_RE, '', val)
|
|
||||||
|
|
||||||
return val
|
|
||||||
|
|
||||||
|
|
||||||
class BaseExporter:
|
class BaseExporter:
|
||||||
@@ -70,8 +59,9 @@ class BaseExporter:
|
|||||||
This is the base class for all data exporters
|
This is the base class for all data exporters
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, event, progress_callback=lambda v: None):
|
def __init__(self, event, organizer, progress_callback=lambda v: None):
|
||||||
self.event = event
|
self.event = event
|
||||||
|
self.organizer = organizer
|
||||||
self.progress_callback = progress_callback
|
self.progress_callback = progress_callback
|
||||||
self.is_multievent = isinstance(event, QuerySet)
|
self.is_multievent = isinstance(event, QuerySet)
|
||||||
if isinstance(event, QuerySet):
|
if isinstance(event, QuerySet):
|
||||||
@@ -220,9 +210,13 @@ class ListExporter(BaseExporter):
|
|||||||
writer.writerow(line)
|
writer.writerow(line)
|
||||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
def prepare_xlsx_sheet(self, ws):
|
||||||
|
pass
|
||||||
|
|
||||||
def _render_xlsx(self, form_data, output_file=None):
|
def _render_xlsx(self, form_data, output_file=None):
|
||||||
wb = Workbook(write_only=True)
|
wb = SafeWorkbook(write_only=True)
|
||||||
ws = wb.create_sheet()
|
ws = wb.create_sheet()
|
||||||
|
self.prepare_xlsx_sheet(ws)
|
||||||
try:
|
try:
|
||||||
ws.title = str(self.verbose_name)
|
ws.title = str(self.verbose_name)
|
||||||
except:
|
except:
|
||||||
@@ -234,7 +228,7 @@ class ListExporter(BaseExporter):
|
|||||||
total = line.total
|
total = line.total
|
||||||
continue
|
continue
|
||||||
ws.append([
|
ws.append([
|
||||||
excel_safe(val) for val in line
|
val for val in line
|
||||||
])
|
])
|
||||||
if total:
|
if total:
|
||||||
counter += 1
|
counter += 1
|
||||||
@@ -339,7 +333,7 @@ class MultiSheetListExporter(ListExporter):
|
|||||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||||
|
|
||||||
def _render_xlsx(self, form_data, output_file=None):
|
def _render_xlsx(self, form_data, output_file=None):
|
||||||
wb = Workbook(write_only=True)
|
wb = SafeWorkbook(write_only=True)
|
||||||
n_sheets = len(self.sheets)
|
n_sheets = len(self.sheets)
|
||||||
for i_sheet, (s, l) in enumerate(self.sheets):
|
for i_sheet, (s, l) in enumerate(self.sheets):
|
||||||
ws = wb.create_sheet(str(l))
|
ws = wb.create_sheet(str(l))
|
||||||
@@ -353,8 +347,7 @@ class MultiSheetListExporter(ListExporter):
|
|||||||
total = line.total
|
total = line.total
|
||||||
continue
|
continue
|
||||||
ws.append([
|
ws.append([
|
||||||
excel_safe(val)
|
val for val in line
|
||||||
for val in line
|
|
||||||
])
|
])
|
||||||
if total:
|
if total:
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|||||||
@@ -324,7 +324,6 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
|||||||
_('Tax rate'),
|
_('Tax rate'),
|
||||||
_('Tax name'),
|
_('Tax name'),
|
||||||
_('Event start date'),
|
_('Event start date'),
|
||||||
|
|
||||||
_('Date'),
|
_('Date'),
|
||||||
_('Order code'),
|
_('Order code'),
|
||||||
_('E-mail address'),
|
_('E-mail address'),
|
||||||
@@ -348,6 +347,8 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
|||||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||||
_('Payment providers'),
|
_('Payment providers'),
|
||||||
|
_('Event end date'),
|
||||||
|
_('Location'),
|
||||||
]
|
]
|
||||||
|
|
||||||
p_providers = OrderPayment.objects.filter(
|
p_providers = OrderPayment.objects.filter(
|
||||||
@@ -406,7 +407,9 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
|||||||
', '.join([
|
', '.join([
|
||||||
str(self.providers.get(p, p)) for p in sorted(set((l.payment_providers or '').split(',')))
|
str(self.providers.get(p, p)) for p in sorted(set((l.payment_providers or '').split(',')))
|
||||||
if p and p != 'free'
|
if p and p != 'free'
|
||||||
])
|
]),
|
||||||
|
date_format(l.event_date_to, "SHORT_DATE_FORMAT") if l.event_date_to else "",
|
||||||
|
l.event_location or "",
|
||||||
]
|
]
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
|
|||||||
@@ -55,16 +55,20 @@ class JSONExporter(BaseExporter):
|
|||||||
'name': str(self.event.organizer.name),
|
'name': str(self.event.organizer.name),
|
||||||
'slug': self.event.organizer.slug
|
'slug': self.event.organizer.slug
|
||||||
},
|
},
|
||||||
|
'meta_data': self.event.meta_data,
|
||||||
'categories': [
|
'categories': [
|
||||||
{
|
{
|
||||||
'id': category.id,
|
'id': category.id,
|
||||||
'name': str(category.name),
|
'name': str(category.name),
|
||||||
|
'description': str(category.description),
|
||||||
|
'position': category.position,
|
||||||
'internal_name': category.internal_name
|
'internal_name': category.internal_name
|
||||||
} for category in self.event.categories.all()
|
} for category in self.event.categories.all()
|
||||||
],
|
],
|
||||||
'items': [
|
'items': [
|
||||||
{
|
{
|
||||||
'id': item.id,
|
'id': item.id,
|
||||||
|
'position': item.position,
|
||||||
'name': str(item.name),
|
'name': str(item.name),
|
||||||
'internal_name': str(item.internal_name),
|
'internal_name': str(item.internal_name),
|
||||||
'category': item.category_id,
|
'category': item.category_id,
|
||||||
@@ -73,13 +77,35 @@ class JSONExporter(BaseExporter):
|
|||||||
'tax_name': str(item.tax_rule.name) if item.tax_rule else None,
|
'tax_name': str(item.tax_rule.name) if item.tax_rule else None,
|
||||||
'admission': item.admission,
|
'admission': item.admission,
|
||||||
'active': item.active,
|
'active': item.active,
|
||||||
|
'sales_channels': item.sales_channels,
|
||||||
|
'description': str(item.description),
|
||||||
|
'available_from': item.available_from,
|
||||||
|
'available_until': item.available_until,
|
||||||
|
'require_voucher': item.require_voucher,
|
||||||
|
'hide_without_voucher': item.hide_without_voucher,
|
||||||
|
'allow_cancel': item.allow_cancel,
|
||||||
|
'require_bundling': item.require_bundling,
|
||||||
|
'min_per_order': item.min_per_order,
|
||||||
|
'max_per_order': item.max_per_order,
|
||||||
|
'checkin_attention': item.checkin_attention,
|
||||||
|
'original_price': item.original_price,
|
||||||
|
'issue_giftcard': item.issue_giftcard,
|
||||||
|
'meta_data': item.meta_data,
|
||||||
|
'require_membership': item.require_membership,
|
||||||
'variations': [
|
'variations': [
|
||||||
{
|
{
|
||||||
'id': variation.id,
|
'id': variation.id,
|
||||||
'active': variation.active,
|
'active': variation.active,
|
||||||
'price': variation.default_price if variation.default_price is not None else
|
'price': variation.default_price if variation.default_price is not None else
|
||||||
item.default_price,
|
item.default_price,
|
||||||
'name': str(variation)
|
'name': str(variation),
|
||||||
|
'description': str(variation.description),
|
||||||
|
'position': variation.position,
|
||||||
|
'require_membership': variation.require_membership,
|
||||||
|
'sales_channels': variation.sales_channels,
|
||||||
|
'available_from': variation.available_from,
|
||||||
|
'available_until': variation.available_until,
|
||||||
|
'hide_without_voucher': variation.hide_without_voucher,
|
||||||
} for variation in item.variations.all()
|
} for variation in item.variations.all()
|
||||||
]
|
]
|
||||||
} for item in self.event.items.select_related('tax_rule').prefetch_related('variations')
|
} for item in self.event.items.select_related('tax_rule').prefetch_related('variations')
|
||||||
@@ -87,7 +113,13 @@ class JSONExporter(BaseExporter):
|
|||||||
'questions': [
|
'questions': [
|
||||||
{
|
{
|
||||||
'id': question.id,
|
'id': question.id,
|
||||||
|
'identifier': question.identifier,
|
||||||
|
'required': question.required,
|
||||||
'question': str(question.question),
|
'question': str(question.question),
|
||||||
|
'position': question.position,
|
||||||
|
'hidden': question.hidden,
|
||||||
|
'ask_during_checkin': question.ask_during_checkin,
|
||||||
|
'help_text': str(question.help_text),
|
||||||
'type': question.type
|
'type': question.type
|
||||||
} for question in self.event.questions.all()
|
} for question in self.event.questions.all()
|
||||||
],
|
],
|
||||||
@@ -95,7 +127,18 @@ class JSONExporter(BaseExporter):
|
|||||||
{
|
{
|
||||||
'code': order.code,
|
'code': order.code,
|
||||||
'status': order.status,
|
'status': order.status,
|
||||||
|
'customer': order.customer.identifier if order.customer else None,
|
||||||
|
'testmode': order.testmode,
|
||||||
'user': order.email,
|
'user': order.email,
|
||||||
|
'email': order.email,
|
||||||
|
'phone': str(order.phone),
|
||||||
|
'locale': order.locale,
|
||||||
|
'comment': order.comment,
|
||||||
|
'custom_followup_at': order.custom_followup_at,
|
||||||
|
'require_approval': order.require_approval,
|
||||||
|
'checkin_attention': order.checkin_attention,
|
||||||
|
'sales_channel': order.sales_channel,
|
||||||
|
'expires': order.expires,
|
||||||
'datetime': order.datetime,
|
'datetime': order.datetime,
|
||||||
'fees': [
|
'fees': [
|
||||||
{
|
{
|
||||||
@@ -108,11 +151,21 @@ class JSONExporter(BaseExporter):
|
|||||||
'positions': [
|
'positions': [
|
||||||
{
|
{
|
||||||
'id': position.id,
|
'id': position.id,
|
||||||
|
'positionid': position.positionid,
|
||||||
'item': position.item_id,
|
'item': position.item_id,
|
||||||
'variation': position.variation_id,
|
'variation': position.variation_id,
|
||||||
|
'subevent': position.subevent_id,
|
||||||
|
'seat': position.seat.seat_guid if position.seat else None,
|
||||||
'price': position.price,
|
'price': position.price,
|
||||||
|
'tax_rate': position.tax_rate,
|
||||||
|
'tax_value': position.tax_value,
|
||||||
'attendee_name': position.attendee_name,
|
'attendee_name': position.attendee_name,
|
||||||
'attendee_email': position.attendee_email,
|
'attendee_email': position.attendee_email,
|
||||||
|
'company': position.company,
|
||||||
|
'street': position.street,
|
||||||
|
'zipcode': position.zipcode,
|
||||||
|
'country': str(position.country) if position.country else None,
|
||||||
|
'state': position.state,
|
||||||
'secret': position.secret,
|
'secret': position.secret,
|
||||||
'addon_to': position.addon_to_id,
|
'addon_to': position.addon_to_id,
|
||||||
'answers': [
|
'answers': [
|
||||||
@@ -124,15 +177,30 @@ class JSONExporter(BaseExporter):
|
|||||||
} for position in order.positions.all()
|
} for position in order.positions.all()
|
||||||
]
|
]
|
||||||
} for order in
|
} for order in
|
||||||
self.event.orders.all().prefetch_related('positions', 'positions__answers', 'fees')
|
self.event.orders.all().prefetch_related('positions', 'positions__answers', 'positions__seat', 'customer', 'fees')
|
||||||
],
|
],
|
||||||
'quotas': [
|
'quotas': [
|
||||||
{
|
{
|
||||||
'id': quota.id,
|
'id': quota.id,
|
||||||
'size': quota.size,
|
'size': quota.size,
|
||||||
|
'subevent': quota.subevent_id,
|
||||||
'items': [item.id for item in quota.items.all()],
|
'items': [item.id for item in quota.items.all()],
|
||||||
'variations': [variation.id for variation in quota.variations.all()],
|
'variations': [variation.id for variation in quota.variations.all()],
|
||||||
} for quota in self.event.quotas.all().prefetch_related('items', 'variations')
|
} for quota in self.event.quotas.all().prefetch_related('items', 'variations')
|
||||||
|
],
|
||||||
|
'subevents': [
|
||||||
|
{
|
||||||
|
'id': se.id,
|
||||||
|
'name': str(se.name),
|
||||||
|
'location': str(se.location),
|
||||||
|
'date_from': se.date_from,
|
||||||
|
'date_to': se.date_to,
|
||||||
|
'date_admission': se.date_admission,
|
||||||
|
'geo_lat': se.geo_lat,
|
||||||
|
'geo_lon': se.geo_lon,
|
||||||
|
'is_public': se.is_public,
|
||||||
|
'meta_data': se.meta_data,
|
||||||
|
} for se in self.event.subevents.all()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from datetime import date, datetime, time
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import dateutil
|
import dateutil
|
||||||
@@ -42,10 +43,10 @@ from django.db.models import (
|
|||||||
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
|
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
|
||||||
Q, Subquery, Sum, When,
|
Q, Subquery, Sum, When,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce, TruncDate
|
from django.db.models.functions import Coalesce
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import get_current_timezone, now
|
from django.utils.timezone import get_current_timezone, make_aware, now
|
||||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext
|
from django.utils.translation import gettext as _, gettext_lazy, pgettext
|
||||||
|
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
@@ -129,7 +130,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
label=_('End event date'),
|
label=_('End event date'),
|
||||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||||
required=False,
|
required=False,
|
||||||
help_text=_('Only include orders including at least one ticket for a date on or after this date. '
|
help_text=_('Only include orders including at least one ticket for a date on or before this date. '
|
||||||
'Will also include other dates in case of mixed orders!')
|
'Will also include other dates in case of mixed orders!')
|
||||||
)),
|
)),
|
||||||
]
|
]
|
||||||
@@ -181,41 +182,43 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
|
|
||||||
if form_data.get('date_from'):
|
if form_data.get('date_from'):
|
||||||
date_value = form_data.get('date_from')
|
date_value = form_data.get('date_from')
|
||||||
if isinstance(date_value, str):
|
if not isinstance(date_value, date):
|
||||||
date_value = dateutil.parser.parse(date_value).date()
|
date_value = dateutil.parser.parse(date_value).date()
|
||||||
|
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||||
|
|
||||||
annotations['date'] = TruncDate(f'{rel}datetime')
|
filters[f'{rel}datetime__gte'] = datetime_value
|
||||||
filters['date__gte'] = date_value
|
|
||||||
|
|
||||||
if form_data.get('date_to'):
|
if form_data.get('date_to'):
|
||||||
date_value = form_data.get('date_to')
|
date_value = form_data.get('date_to')
|
||||||
if isinstance(date_value, str):
|
if not isinstance(date_value, date):
|
||||||
date_value = dateutil.parser.parse(date_value).date()
|
date_value = dateutil.parser.parse(date_value).date()
|
||||||
|
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||||
|
|
||||||
annotations['date'] = TruncDate(f'{rel}datetime')
|
filters[f'{rel}datetime__lte'] = datetime_value
|
||||||
filters['date__lte'] = date_value
|
|
||||||
|
|
||||||
if form_data.get('event_date_from'):
|
if form_data.get('event_date_from'):
|
||||||
date_value = form_data.get('event_date_from')
|
date_value = form_data.get('event_date_from')
|
||||||
if isinstance(date_value, str):
|
if not isinstance(date_value, date):
|
||||||
date_value = dateutil.parser.parse(date_value).date()
|
date_value = dateutil.parser.parse(date_value).date()
|
||||||
|
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||||
|
|
||||||
annotations['event_date_max'] = Case(
|
annotations['event_date_max'] = Case(
|
||||||
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
|
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
|
||||||
default=F(f'{rel}event__date_from'),
|
default=F(f'{rel}event__date_from'),
|
||||||
)
|
)
|
||||||
filters['event_date_max__gte'] = date_value
|
filters['event_date_max__gte'] = datetime_value
|
||||||
|
|
||||||
if form_data.get('event_date_to'):
|
if form_data.get('event_date_to'):
|
||||||
date_value = form_data.get('event_date_to')
|
date_value = form_data.get('event_date_to')
|
||||||
if isinstance(date_value, str):
|
if not isinstance(date_value, date):
|
||||||
date_value = dateutil.parser.parse(date_value).date()
|
date_value = dateutil.parser.parse(date_value).date()
|
||||||
|
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||||
|
|
||||||
annotations['event_date_min'] = Case(
|
annotations['event_date_min'] = Case(
|
||||||
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
|
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
|
||||||
default=F(f'{rel}event__date_from'),
|
default=F(f'{rel}event__date_from'),
|
||||||
)
|
)
|
||||||
filters['event_date_min__lte'] = date_value
|
filters['event_date_min__lte'] = datetime_value
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
return qs.annotate(**annotations).filter(**filters)
|
return qs.annotate(**annotations).filter(**filters)
|
||||||
@@ -870,6 +873,78 @@ class QuotaListExporter(ListExporter):
|
|||||||
return '{}_quotas'.format(self.event.slug)
|
return '{}_quotas'.format(self.event.slug)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_GiftCardTransactionListExporter(organizer): # hackhack
|
||||||
|
class GiftcardTransactionListExporter(ListExporter):
|
||||||
|
identifier = 'giftcardtransactionlist'
|
||||||
|
verbose_name = gettext_lazy('Gift card transactions')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def additional_form_fields(self):
|
||||||
|
d = [
|
||||||
|
('date_from',
|
||||||
|
forms.DateField(
|
||||||
|
label=_('Start date'),
|
||||||
|
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||||
|
required=False,
|
||||||
|
)),
|
||||||
|
('date_to',
|
||||||
|
forms.DateField(
|
||||||
|
label=_('End date'),
|
||||||
|
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||||
|
required=False,
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
d = OrderedDict(d)
|
||||||
|
return d
|
||||||
|
|
||||||
|
def iterate_list(self, form_data):
|
||||||
|
qs = GiftCardTransaction.objects.filter(
|
||||||
|
card__issuer=organizer,
|
||||||
|
).order_by('datetime').select_related('card', 'order', 'order__event')
|
||||||
|
|
||||||
|
if form_data.get('date_from'):
|
||||||
|
date_value = form_data.get('date_from')
|
||||||
|
if isinstance(date_value, str):
|
||||||
|
date_value = dateutil.parser.parse(date_value).date()
|
||||||
|
qs = qs.filter(
|
||||||
|
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||||
|
)
|
||||||
|
|
||||||
|
if form_data.get('date_to'):
|
||||||
|
date_value = form_data.get('date_to')
|
||||||
|
if isinstance(date_value, str):
|
||||||
|
date_value = dateutil.parser.parse(date_value).date()
|
||||||
|
|
||||||
|
qs = qs.filter(
|
||||||
|
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
_('Gift card code'),
|
||||||
|
_('Test mode'),
|
||||||
|
_('Date'),
|
||||||
|
_('Amount'),
|
||||||
|
_('Currency'),
|
||||||
|
_('Order'),
|
||||||
|
]
|
||||||
|
yield headers
|
||||||
|
|
||||||
|
for obj in qs:
|
||||||
|
row = [
|
||||||
|
obj.card.secret,
|
||||||
|
_('TEST MODE') if obj.card.testmode else '',
|
||||||
|
obj.datetime.astimezone(self.timezone).strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
obj.value,
|
||||||
|
obj.card.currency,
|
||||||
|
obj.order.full_code if obj.order else None,
|
||||||
|
]
|
||||||
|
yield row
|
||||||
|
|
||||||
|
def get_filename(self):
|
||||||
|
return '{}_giftcardtransactions'.format(organizer.slug)
|
||||||
|
return GiftcardTransactionListExporter
|
||||||
|
|
||||||
|
|
||||||
class GiftcardRedemptionListExporter(ListExporter):
|
class GiftcardRedemptionListExporter(ListExporter):
|
||||||
identifier = 'giftcardredemptionlist'
|
identifier = 'giftcardredemptionlist'
|
||||||
verbose_name = gettext_lazy('Gift card redemptions')
|
verbose_name = gettext_lazy('Gift card redemptions')
|
||||||
@@ -1062,3 +1137,8 @@ def register_multievent_i_giftcardredemptionlist_exporter(sender, **kwargs):
|
|||||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardlist")
|
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardlist")
|
||||||
def register_multievent_i_giftcardlist_exporter(sender, **kwargs):
|
def register_multievent_i_giftcardlist_exporter(sender, **kwargs):
|
||||||
return generate_GiftCardListExporter(sender)
|
return generate_GiftCardListExporter(sender)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardtransactionlist")
|
||||||
|
def register_multievent_i_giftcardtransactionlist_exporter(sender, **kwargs):
|
||||||
|
return generate_GiftCardTransactionListExporter(sender)
|
||||||
|
|||||||
@@ -118,6 +118,27 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
|||||||
self.cleaned_data[k] = self.initial[k]
|
self.cleaned_data[k] = self.initial[k]
|
||||||
return super().save()
|
return super().save()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
d = super().clean()
|
||||||
|
|
||||||
|
# There is logic in HierarkeyForm.save() to only persist fields that changed. HierarkeyForm determines if
|
||||||
|
# something changed by comparing `self._s.get(name)` to `value`. This leaves an edge case open for multi-lingual
|
||||||
|
# text fields. On the very first load, the initial value in `self._s.get(name)` will be a LazyGettextProxy-based
|
||||||
|
# string. However, only some of the languages are usually visible, so even if the user does not change anything
|
||||||
|
# at all, it will be considered a changed value and stored. We do not want that, as it makes it very hard to add
|
||||||
|
# languages to an organizer/event later on. So we trick it and make sure nothing gets changed in that situation.
|
||||||
|
for name, field in self.fields.items():
|
||||||
|
if isinstance(field, i18nfield.forms.I18nFormField):
|
||||||
|
value = d.get(name)
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current = self._s.get(name, as_type=type(value))
|
||||||
|
if name not in self.changed_data:
|
||||||
|
d[name] = current
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
def get_new_filename(self, name: str) -> str:
|
def get_new_filename(self, name: str) -> str:
|
||||||
from pretix.base.models import Event
|
from pretix.base.models import Event
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class RegistrationForm(forms.Form):
|
|||||||
widget=forms.PasswordInput(attrs={
|
widget=forms.PasswordInput(attrs={
|
||||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||||
}),
|
}),
|
||||||
|
max_length=4096,
|
||||||
required=True
|
required=True
|
||||||
)
|
)
|
||||||
password_repeat = forms.CharField(
|
password_repeat = forms.CharField(
|
||||||
@@ -161,6 +162,7 @@ class RegistrationForm(forms.Form):
|
|||||||
widget=forms.PasswordInput(attrs={
|
widget=forms.PasswordInput(attrs={
|
||||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||||
}),
|
}),
|
||||||
|
max_length=4096,
|
||||||
required=True
|
required=True
|
||||||
)
|
)
|
||||||
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
||||||
@@ -204,11 +206,13 @@ class PasswordRecoverForm(forms.Form):
|
|||||||
password = forms.CharField(
|
password = forms.CharField(
|
||||||
label=_('Password'),
|
label=_('Password'),
|
||||||
widget=forms.PasswordInput,
|
widget=forms.PasswordInput,
|
||||||
|
max_length=4096,
|
||||||
required=True
|
required=True
|
||||||
)
|
)
|
||||||
password_repeat = forms.CharField(
|
password_repeat = forms.CharField(
|
||||||
label=_('Repeat password'),
|
label=_('Repeat password'),
|
||||||
widget=forms.PasswordInput
|
widget=forms.PasswordInput,
|
||||||
|
max_length=4096,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, user_id=None, *args, **kwargs):
|
def __init__(self, user_id=None, *args, **kwargs):
|
||||||
|
|||||||
@@ -37,21 +37,19 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from urllib.error import HTTPError
|
|
||||||
|
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
import pycountry
|
import pycountry
|
||||||
import pytz
|
import pytz
|
||||||
import vat_moss.errors
|
|
||||||
import vat_moss.id
|
|
||||||
from babel import Locale
|
from babel import Locale
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.forms import Select
|
from django.forms import Select, widgets
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
@@ -75,8 +73,9 @@ from pretix.base.i18n import (
|
|||||||
get_babel_locale, get_language_without_region, language,
|
get_babel_locale, get_language_without_region, language,
|
||||||
)
|
)
|
||||||
from pretix.base.models import InvoiceAddress, Question, QuestionOption
|
from pretix.base.models import InvoiceAddress, Question, QuestionOption
|
||||||
from pretix.base.models.tax import (
|
from pretix.base.models.tax import VAT_ID_COUNTRIES, ask_for_vat_id
|
||||||
EU_COUNTRIES, cc_to_vat_prefix, is_eu_country,
|
from pretix.base.services.tax import (
|
||||||
|
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
|
||||||
)
|
)
|
||||||
from pretix.base.settings import (
|
from pretix.base.settings import (
|
||||||
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS,
|
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS,
|
||||||
@@ -153,8 +152,9 @@ class NamePartsWidget(forms.MultiWidget):
|
|||||||
final_attrs,
|
final_attrs,
|
||||||
id='%s_%s' % (id_, i),
|
id='%s_%s' % (id_, i),
|
||||||
title=self.scheme['fields'][i][1],
|
title=self.scheme['fields'][i][1],
|
||||||
placeholder=self.scheme['fields'][i][1],
|
|
||||||
)
|
)
|
||||||
|
if not isinstance(widget, widgets.Select):
|
||||||
|
these_attrs['placeholder'] = self.scheme['fields'][i][1]
|
||||||
if self.scheme['fields'][i][0] in REQUIRED_NAME_PARTS:
|
if self.scheme['fields'][i][0] in REQUIRED_NAME_PARTS:
|
||||||
if self.field.required:
|
if self.field.required:
|
||||||
these_attrs['required'] = 'required'
|
these_attrs['required'] = 'required'
|
||||||
@@ -333,23 +333,41 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
|||||||
def guess_country(event):
|
def guess_country(event):
|
||||||
# Try to guess the initial country from either the country of the merchant
|
# Try to guess the initial country from either the country of the merchant
|
||||||
# or the locale. This will hopefully save at least some users some scrolling :)
|
# or the locale. This will hopefully save at least some users some scrolling :)
|
||||||
locale = get_language_without_region()
|
|
||||||
country = event.settings.region or event.settings.invoice_address_from_country
|
country = event.settings.region or event.settings.invoice_address_from_country
|
||||||
if not country:
|
if not country:
|
||||||
valid_countries = countries.countries
|
country = get_country_by_locale(get_language_without_region())
|
||||||
if '-' in locale:
|
|
||||||
parts = locale.split('-')
|
|
||||||
# TODO: does this actually work?
|
|
||||||
if parts[1].upper() in valid_countries:
|
|
||||||
country = Country(parts[1].upper())
|
|
||||||
elif parts[0].upper() in valid_countries:
|
|
||||||
country = Country(parts[0].upper())
|
|
||||||
else:
|
|
||||||
if locale.upper() in valid_countries:
|
|
||||||
country = Country(locale.upper())
|
|
||||||
return country
|
return country
|
||||||
|
|
||||||
|
|
||||||
|
def get_country_by_locale(locale):
|
||||||
|
country = None
|
||||||
|
valid_countries = countries.countries
|
||||||
|
if '-' in locale:
|
||||||
|
parts = locale.split('-')
|
||||||
|
# TODO: does this actually work?
|
||||||
|
if parts[1].upper() in valid_countries:
|
||||||
|
country = Country(parts[1].upper())
|
||||||
|
elif parts[0].upper() in valid_countries:
|
||||||
|
country = Country(parts[0].upper())
|
||||||
|
else:
|
||||||
|
if locale.upper() in valid_countries:
|
||||||
|
country = Country(locale.upper())
|
||||||
|
return country
|
||||||
|
|
||||||
|
|
||||||
|
def guess_phone_prefix(event):
|
||||||
|
with language(get_babel_locale()):
|
||||||
|
country = str(guess_country(event))
|
||||||
|
return get_phone_prefix(country)
|
||||||
|
|
||||||
|
|
||||||
|
def get_phone_prefix(country):
|
||||||
|
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||||
|
if country in values:
|
||||||
|
return prefix
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
|
class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
|
||||||
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
|
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
|
||||||
|
|
||||||
@@ -507,7 +525,7 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
kwargs.setdefault('ext_whitelist', (".png", ".jpg", ".jpeg", ".jfif", ".tif", ".tiff", ".bmp"))
|
kwargs.setdefault('ext_whitelist', (".png", ".jpg", ".jpeg", ".jfif", ".tif", ".tiff", ".bmp"))
|
||||||
kwargs.setdefault('max_size', 10 * 1024 * 1024)
|
kwargs.setdefault('max_size', settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@@ -674,7 +692,7 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
label=label, required=required,
|
label=label, required=required,
|
||||||
min_value=q.valid_number_min or Decimal('0.00'),
|
min_value=q.valid_number_min or Decimal('0.00'),
|
||||||
max_value=q.valid_number_max,
|
max_value=q.valid_number_max,
|
||||||
help_text=q.help_text,
|
help_text=help_text,
|
||||||
initial=initial.answer if initial else None,
|
initial=initial.answer if initial else None,
|
||||||
)
|
)
|
||||||
elif q.type == Question.TYPE_STRING:
|
elif q.type == Question.TYPE_STRING:
|
||||||
@@ -739,7 +757,7 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages",
|
".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages",
|
||||||
".bmp", ".tif", ".tiff"
|
".bmp", ".tif", ".tiff"
|
||||||
),
|
),
|
||||||
max_size=10 * 1024 * 1024,
|
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
|
||||||
)
|
)
|
||||||
elif q.type == Question.TYPE_DATE:
|
elif q.type == Question.TYPE_DATE:
|
||||||
attrs = {}
|
attrs = {}
|
||||||
@@ -780,25 +798,26 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
if q.valid_datetime_max:
|
if q.valid_datetime_max:
|
||||||
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
||||||
elif q.type == Question.TYPE_PHONENUMBER:
|
elif q.type == Question.TYPE_PHONENUMBER:
|
||||||
with language(get_babel_locale()):
|
if initial:
|
||||||
default_country = guess_country(event)
|
|
||||||
default_prefix = None
|
|
||||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
|
||||||
if str(default_country) in values:
|
|
||||||
default_prefix = prefix
|
|
||||||
try:
|
try:
|
||||||
initial = PhoneNumber().from_string(initial.answer) if initial else "+{}.".format(default_prefix)
|
initial = PhoneNumber().from_string(initial.answer)
|
||||||
except NumberParseException:
|
except NumberParseException:
|
||||||
initial = None
|
initial = None
|
||||||
field = PhoneNumberField(
|
|
||||||
label=label, required=required,
|
if not initial:
|
||||||
help_text=help_text,
|
phone_prefix = guess_phone_prefix(event)
|
||||||
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
|
if phone_prefix:
|
||||||
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
|
initial = "+{}.".format(phone_prefix)
|
||||||
# the future.
|
|
||||||
initial=initial,
|
field = PhoneNumberField(
|
||||||
widget=WrappedPhoneNumberPrefixWidget()
|
label=label, required=required,
|
||||||
)
|
help_text=help_text,
|
||||||
|
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
|
||||||
|
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
|
||||||
|
# the future.
|
||||||
|
initial=initial,
|
||||||
|
widget=WrappedPhoneNumberPrefixWidget()
|
||||||
|
)
|
||||||
field.question = q
|
field.question = q
|
||||||
if answers:
|
if answers:
|
||||||
# Cache the answer object for later use
|
# Cache the answer object for later use
|
||||||
@@ -869,6 +888,12 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
if question_is_required(q) and not answer and answer != 0 and not field.errors:
|
if question_is_required(q) and not answer and answer != 0 and not field.errors:
|
||||||
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
|
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
|
||||||
|
|
||||||
|
# Strip invisible question from cleaned_data so they don't end up in the database
|
||||||
|
for q in question_cache.values():
|
||||||
|
answer = d.get('question_%d' % q.pk)
|
||||||
|
if q.dependency_question_id and not question_is_visible(q.dependency_question_id, q.dependency_values) and answer is not None:
|
||||||
|
d['question_%d' % q.pk] = None
|
||||||
|
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -900,7 +925,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
'data-display-dependency': '#id_is_business_1',
|
'data-display-dependency': '#id_is_business_1',
|
||||||
'autocomplete': 'organization',
|
'autocomplete': 'organization',
|
||||||
}),
|
}),
|
||||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-in-eu': ','.join(EU_COUNTRIES)}),
|
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-with-vat-id': ','.join(VAT_ID_COUNTRIES)}),
|
||||||
'internal_reference': forms.TextInput,
|
'internal_reference': forms.TextInput,
|
||||||
}
|
}
|
||||||
labels = {
|
labels = {
|
||||||
@@ -920,6 +945,18 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if not event.settings.invoice_address_vatid:
|
if not event.settings.invoice_address_vatid:
|
||||||
del self.fields['vat_id']
|
del self.fields['vat_id']
|
||||||
|
elif self.validate_vat_id:
|
||||||
|
self.fields['vat_id'].help_text = '<br/>'.join([
|
||||||
|
str(_('Optional, but depending on the country you reside in we might need to charge you '
|
||||||
|
'additional taxes if you do not enter it.')),
|
||||||
|
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
self.fields['vat_id'].help_text = '<br/>'.join([
|
||||||
|
str(_('Optional, but it might be required for you to claim tax benefits on your invoice '
|
||||||
|
'depending on your and the seller’s country of residence.')),
|
||||||
|
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
|
||||||
|
])
|
||||||
|
|
||||||
self.fields['country'].choices = CachedCountries()
|
self.fields['country'].choices = CachedCountries()
|
||||||
|
|
||||||
@@ -951,7 +988,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
self.fields['state'].widget.is_required = True
|
self.fields['state'].widget.is_required = True
|
||||||
|
|
||||||
# Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected.
|
# Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected.
|
||||||
if cc and not is_eu_country(cc) and fprefix + 'vat_id' in self.data:
|
if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data:
|
||||||
self.data = self.data.copy()
|
self.data = self.data.copy()
|
||||||
del self.data[fprefix + 'vat_id']
|
del self.data[fprefix + 'vat_id']
|
||||||
|
|
||||||
@@ -976,7 +1013,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
scheme=event.settings.name_scheme,
|
scheme=event.settings.name_scheme,
|
||||||
titles=event.settings.name_scheme_titles,
|
titles=event.settings.name_scheme_titles,
|
||||||
label=_('Name'),
|
label=_('Name'),
|
||||||
initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
|
initial=self.instance.name_parts,
|
||||||
)
|
)
|
||||||
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
|
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
|
||||||
if not event.settings.invoice_name_required:
|
if not event.settings.invoice_name_required:
|
||||||
@@ -1001,7 +1038,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
if not data.get('is_business'):
|
if not data.get('is_business'):
|
||||||
data['company'] = ''
|
data['company'] = ''
|
||||||
data['vat_id'] = ''
|
data['vat_id'] = ''
|
||||||
if data.get('is_business') and not is_eu_country(data.get('country')):
|
if data.get('is_business') and not ask_for_vat_id(data.get('country')):
|
||||||
data['vat_id'] = ''
|
data['vat_id'] = ''
|
||||||
if self.event.settings.invoice_address_required:
|
if self.event.settings.invoice_address_required:
|
||||||
if data.get('is_business') and not data.get('company'):
|
if data.get('is_business') and not data.get('company'):
|
||||||
@@ -1024,36 +1061,23 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
# Do not save the country if it is the only field set -- we don't know the user even checked it!
|
# Do not save the country if it is the only field set -- we don't know the user even checked it!
|
||||||
self.cleaned_data['country'] = ''
|
self.cleaned_data['country'] = ''
|
||||||
|
|
||||||
if data.get('vat_id') and is_eu_country(data.get('country')) and data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))):
|
|
||||||
raise ValidationError(_('Your VAT ID does not match the selected country.'))
|
|
||||||
|
|
||||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||||
pass
|
pass
|
||||||
elif self.validate_vat_id and data.get('is_business') and is_eu_country(data.get('country')) and data.get('vat_id'):
|
elif self.validate_vat_id and data.get('is_business') and ask_for_vat_id(data.get('country')) and data.get('vat_id'):
|
||||||
try:
|
try:
|
||||||
result = vat_moss.id.validate(data.get('vat_id'))
|
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
|
||||||
if result:
|
self.instance.vat_id_validated = True
|
||||||
country_code, normalized_id, company_name = result
|
self.instance.vat_id = normalized_id
|
||||||
self.instance.vat_id_validated = True
|
except VATIDFinalError as e:
|
||||||
self.instance.vat_id = normalized_id
|
if self.all_optional:
|
||||||
except (vat_moss.errors.InvalidError, ValueError):
|
self.instance.vat_id_validated = False
|
||||||
raise ValidationError(_('This VAT ID is not valid. Please re-check your input.'))
|
messages.warning(self.request, e.message)
|
||||||
except vat_moss.errors.WebServiceUnavailableError:
|
else:
|
||||||
logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
|
raise ValidationError(e.message)
|
||||||
|
except VATIDTemporaryError as e:
|
||||||
self.instance.vat_id_validated = False
|
self.instance.vat_id_validated = False
|
||||||
if self.request and self.vat_warning:
|
if self.request and self.vat_warning:
|
||||||
messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of '
|
messages.warning(self.request, e.message)
|
||||||
'your country is currently not available. We will therefore '
|
|
||||||
'need to charge VAT on your invoice. You can get the tax amount '
|
|
||||||
'back via the VAT reimbursement process.'))
|
|
||||||
except (vat_moss.errors.WebServiceError, HTTPError):
|
|
||||||
logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
|
|
||||||
self.instance.vat_id_validated = False
|
|
||||||
if self.request and self.vat_warning:
|
|
||||||
messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of '
|
|
||||||
'your country returned an incorrect result. We will therefore '
|
|
||||||
'need to charge VAT on your invoice. Please contact support to '
|
|
||||||
'resolve this manually.'))
|
|
||||||
else:
|
else:
|
||||||
self.instance.vat_id_validated = False
|
self.instance.vat_id_validated = False
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
'pw_current_wrong': _("The current password you entered was not correct."),
|
'pw_current_wrong': _("The current password you entered was not correct."),
|
||||||
'pw_mismatch': _("Please enter the same password twice"),
|
'pw_mismatch': _("Please enter the same password twice"),
|
||||||
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||||
|
'pw_equal': _("Please choose a password different to your current one.")
|
||||||
}
|
}
|
||||||
|
|
||||||
old_pw = forms.CharField(max_length=255,
|
old_pw = forms.CharField(max_length=255,
|
||||||
@@ -158,6 +159,12 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
code='pw_current'
|
code='pw_current'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if password1 and password1 == old_pw:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['pw_equal'],
|
||||||
|
code='pw_equal'
|
||||||
|
)
|
||||||
|
|
||||||
if password1:
|
if password1:
|
||||||
self.instance.set_password(password1)
|
self.instance.set_password(password1)
|
||||||
|
|
||||||
|
|||||||
@@ -86,14 +86,6 @@ class TimePickerWidget(forms.TimeInput):
|
|||||||
|
|
||||||
class UploadedFileWidget(forms.ClearableFileInput):
|
class UploadedFileWidget(forms.ClearableFileInput):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# Browsers can't recognize that the server already has a file uploaded
|
|
||||||
# Don't mark this input as being required if we already have an answer
|
|
||||||
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
|
|
||||||
attrs = kwargs.get('attrs', {})
|
|
||||||
if kwargs.get('required') and kwargs.get('initial'):
|
|
||||||
attrs.update({'required': None})
|
|
||||||
kwargs.update({'attrs': attrs})
|
|
||||||
|
|
||||||
self.position = kwargs.pop('position')
|
self.position = kwargs.pop('position')
|
||||||
self.event = kwargs.pop('event')
|
self.event = kwargs.pop('event')
|
||||||
self.answer = kwargs.pop('answer')
|
self.answer = kwargs.pop('answer')
|
||||||
@@ -125,6 +117,15 @@ class UploadedFileWidget(forms.ClearableFileInput):
|
|||||||
'answer': self.answer.pk,
|
'answer': self.answer.pk,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def get_context(self, name, value, attrs):
|
||||||
|
# Browsers can't recognize that the server already has a file uploaded
|
||||||
|
# Don't mark this input as being required if we already have an answer
|
||||||
|
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
|
||||||
|
ctx = super().get_context(name, value, attrs)
|
||||||
|
if ctx['widget']['is_initial']:
|
||||||
|
ctx['widget']['attrs']['required'] = False
|
||||||
|
return ctx
|
||||||
|
|
||||||
def format_value(self, value):
|
def format_value(self, value):
|
||||||
if self.is_initial(value):
|
if self.is_initial(value):
|
||||||
return self.FakeFile(value, self.position, self.event, self.answer)
|
return self.FakeFile(value, self.position, self.event, self.answer)
|
||||||
@@ -184,7 +185,7 @@ class BusinessBooleanRadio(forms.RadioSelect):
|
|||||||
self.require_business = require_business
|
self.require_business = require_business
|
||||||
if self.require_business:
|
if self.require_business:
|
||||||
choices = (
|
choices = (
|
||||||
('business', _('Business customer')),
|
('business', _('Business or institutional customer')),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
choices = (
|
choices = (
|
||||||
|
|||||||
@@ -395,7 +395,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
return txt
|
return txt
|
||||||
|
|
||||||
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
|
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
|
||||||
if self.invoice.event.settings.show_date_to and self.invoice.event.date_to:
|
tz = self.invoice.event.timezone
|
||||||
|
show_end_date = (
|
||||||
|
self.invoice.event.settings.show_date_to and
|
||||||
|
self.invoice.event.date_to and
|
||||||
|
self.invoice.event.date_to.astimezone(tz).date() != self.invoice.event.date_from.astimezone(tz).date()
|
||||||
|
)
|
||||||
|
if show_end_date:
|
||||||
p_str = (
|
p_str = (
|
||||||
shorten(self.invoice.event.name) + '\n' +
|
shorten(self.invoice.event.name) + '\n' +
|
||||||
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
|
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
|
||||||
@@ -550,7 +556,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
for line in self.invoice.lines.all():
|
for line in self.invoice.lines.all():
|
||||||
if has_taxes:
|
if has_taxes:
|
||||||
tdata.append((
|
tdata.append((
|
||||||
Paragraph(line.description, self.stylesheet['Normal']),
|
Paragraph(
|
||||||
|
bleach.clean(line.description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
|
||||||
|
self.stylesheet['Normal']
|
||||||
|
),
|
||||||
"1",
|
"1",
|
||||||
localize(line.tax_rate) + " %",
|
localize(line.tax_rate) + " %",
|
||||||
money_filter(line.net_value, self.invoice.event.currency),
|
money_filter(line.net_value, self.invoice.event.currency),
|
||||||
@@ -558,7 +567,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
tdata.append((
|
tdata.append((
|
||||||
Paragraph(line.description, self.stylesheet['Normal']),
|
Paragraph(
|
||||||
|
bleach.clean(line.description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
|
||||||
|
self.stylesheet['Normal']
|
||||||
|
),
|
||||||
"1",
|
"1",
|
||||||
money_filter(line.gross_value, self.invoice.event.currency),
|
money_filter(line.gross_value, self.invoice.event.currency),
|
||||||
))
|
))
|
||||||
@@ -595,7 +607,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
table.setStyle(TableStyle(tstyledata))
|
table.setStyle(TableStyle(tstyledata))
|
||||||
story.append(table)
|
story.append(table)
|
||||||
|
|
||||||
story.append(Spacer(1, 15 * mm))
|
story.append(Spacer(1, 10 * mm))
|
||||||
|
|
||||||
if self.invoice.payment_provider_text:
|
if self.invoice.payment_provider_text:
|
||||||
story.append(Paragraph(
|
story.append(Paragraph(
|
||||||
@@ -611,12 +623,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
self.invoice.additional_text,
|
self.invoice.additional_text,
|
||||||
self.stylesheet['Normal']
|
self.stylesheet['Normal']
|
||||||
))
|
))
|
||||||
story.append(Spacer(1, 15 * mm))
|
story.append(Spacer(1, 5 * mm))
|
||||||
|
|
||||||
tstyledata = [
|
tstyledata = [
|
||||||
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
||||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 1),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, -1), 1),
|
||||||
('FONTSIZE', (0, 0), (-1, -1), 8),
|
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||||
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
|
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
|
||||||
]
|
]
|
||||||
@@ -769,44 +783,55 @@ class Modern1Renderer(ClassicInvoiceRenderer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def _draw_metadata(self, canvas):
|
def _draw_metadata(self, canvas):
|
||||||
|
# Draws the "invoice number -- date" line. This has gotten a little more complicated since we
|
||||||
|
# encountered some events with very long invoice numbers. In this case, we automatically reduce
|
||||||
|
# the font size until it fits.
|
||||||
begin_top = 100 * mm
|
begin_top = 100 * mm
|
||||||
|
|
||||||
textobject = canvas.beginText(self.left_margin, self.pagesize[1] - begin_top)
|
def _draw(label, value, value_size, x, width):
|
||||||
textobject.setFont(self.font_regular, 8)
|
if canvas.stringWidth(value, self.font_regular, value_size) > width and value_size > 6:
|
||||||
textobject.textLine(pgettext('invoice', 'Order code'))
|
return False
|
||||||
textobject.moveCursor(0, 5)
|
textobject = canvas.beginText(x, self.pagesize[1] - begin_top)
|
||||||
textobject.setFont(self.font_regular, 10)
|
|
||||||
textobject.textLine(self.invoice.order.full_code)
|
|
||||||
canvas.drawText(textobject)
|
|
||||||
|
|
||||||
if self.invoice.is_cancellation:
|
|
||||||
textobject = canvas.beginText(self.left_margin + 50 * mm, self.pagesize[1] - begin_top)
|
|
||||||
textobject.setFont(self.font_regular, 8)
|
textobject.setFont(self.font_regular, 8)
|
||||||
textobject.textLine(pgettext('invoice', 'Cancellation number'))
|
textobject.textLine(label)
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
textobject.setFont(self.font_regular, 10)
|
textobject.setFont(self.font_regular, value_size)
|
||||||
textobject.textLine(self.invoice.number)
|
textobject.textLine(value)
|
||||||
canvas.drawText(textobject)
|
return textobject
|
||||||
|
|
||||||
textobject = canvas.beginText(self.left_margin + 100 * mm, self.pagesize[1] - begin_top)
|
value_size = 10
|
||||||
textobject.setFont(self.font_regular, 8)
|
while value_size >= 5:
|
||||||
textobject.textLine(pgettext('invoice', 'Original invoice'))
|
objects = [
|
||||||
textobject.moveCursor(0, 5)
|
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm)
|
||||||
textobject.setFont(self.font_regular, 10)
|
]
|
||||||
textobject.textLine(self.invoice.refers.number)
|
|
||||||
canvas.drawText(textobject)
|
p = Paragraph(
|
||||||
else:
|
date_format(self.invoice.date, "DATE_FORMAT"),
|
||||||
textobject = canvas.beginText(self.left_margin + 70 * mm, self.pagesize[1] - begin_top)
|
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
|
||||||
textobject.textLine(pgettext('invoice', 'Invoice number'))
|
)
|
||||||
textobject.moveCursor(0, 5)
|
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
|
||||||
textobject.setFont(self.font_regular, 10)
|
p.wrapOn(canvas, w, 15 * mm)
|
||||||
textobject.textLine(self.invoice.number)
|
date_x = self.pagesize[0] - w - self.right_margin
|
||||||
canvas.drawText(textobject)
|
|
||||||
|
if self.invoice.is_cancellation:
|
||||||
|
objects += [
|
||||||
|
_draw(pgettext('invoice', 'Cancellation number'), self.invoice.number,
|
||||||
|
value_size, self.left_margin + 50 * mm, 45 * mm),
|
||||||
|
_draw(pgettext('invoice', 'Original invoice'), self.invoice.refers.number,
|
||||||
|
value_size, self.left_margin + 100 * mm, date_x - self.left_margin - 100 * mm - 5 * mm),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
objects += [
|
||||||
|
_draw(pgettext('invoice', 'Invoice number'), self.invoice.number,
|
||||||
|
value_size, self.left_margin + 70 * mm, date_x - self.left_margin - 70 * mm - 5 * mm),
|
||||||
|
]
|
||||||
|
|
||||||
|
if all(objects):
|
||||||
|
for o in objects:
|
||||||
|
canvas.drawText(o)
|
||||||
|
break
|
||||||
|
value_size -= 1
|
||||||
|
|
||||||
p = Paragraph(date_format(self.invoice.date, "DATE_FORMAT"), style=self.stylesheet['Normal'])
|
|
||||||
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
|
|
||||||
p.wrapOn(canvas, w, 15 * mm)
|
|
||||||
date_x = self.pagesize[0] - w - self.right_margin
|
|
||||||
p.drawOn(canvas, date_x, self.pagesize[1] - begin_top - 10 - 6)
|
p.drawOn(canvas, date_x, self.pagesize[1] - begin_top - 10 - 6)
|
||||||
|
|
||||||
textobject = canvas.beginText(date_x, self.pagesize[1] - begin_top)
|
textobject = canvas.beginText(date_x, self.pagesize[1] - begin_top)
|
||||||
|
|||||||
67
src/pretix/base/management/commands/_migrations.py
Normal file
67
src/pretix/base/management/commands/_migrations.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
"""
|
||||||
|
Django, for theoretically very valid reasons, creates migrations for *every single thing*
|
||||||
|
we change on a model. Even the `help_text`! This makes sense, as we don't know if any
|
||||||
|
database backend unknown to us might actually use this information for its database schema.
|
||||||
|
|
||||||
|
However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
|
||||||
|
certain that some changes to models will never require a change to the database. In this case,
|
||||||
|
not creating a migration for certain changes will save us some performance while applying them
|
||||||
|
*and* allow for a cleaner git history. Win-win!
|
||||||
|
|
||||||
|
Only caveat is that we need to do some dirty monkeypatching to achieve it...
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.db.migrations.operations import models as modelops
|
||||||
|
from django_countries.fields import CountryField
|
||||||
|
|
||||||
|
|
||||||
|
def monkeypatch_migrations():
|
||||||
|
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
|
||||||
|
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
|
||||||
|
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
|
||||||
|
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
|
||||||
|
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
|
||||||
|
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
|
||||||
|
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
|
||||||
|
IGNORED_ATTRS = [
|
||||||
|
# (field type, attribute name, banlist of field sub-types)
|
||||||
|
(models.Field, 'verbose_name', []),
|
||||||
|
(models.Field, 'help_text', []),
|
||||||
|
(models.Field, 'validators', []),
|
||||||
|
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
|
||||||
|
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
|
||||||
|
models.TimeField]),
|
||||||
|
(models.CharField, 'choices', [CountryField])
|
||||||
|
]
|
||||||
|
|
||||||
|
original_deconstruct = models.Field.deconstruct
|
||||||
|
|
||||||
|
def new_deconstruct(self):
|
||||||
|
name, path, args, kwargs = original_deconstruct(self)
|
||||||
|
for ftype, attr, banlist in IGNORED_ATTRS:
|
||||||
|
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in banlist):
|
||||||
|
kwargs.pop(attr, None)
|
||||||
|
return name, path, args, kwargs
|
||||||
|
|
||||||
|
models.Field.deconstruct = new_deconstruct
|
||||||
107
src/pretix/base/management/commands/check_order_transactions.py
Normal file
107
src/pretix/base/management/commands/check_order_transactions.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import (
|
||||||
|
Case, Count, F, OuterRef, Q, Subquery, Sum, Value, When,
|
||||||
|
)
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
|
from pretix.base.models import Order, OrderFee, OrderPosition
|
||||||
|
from pretix.base.models.orders import Transaction
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Check order for consistency with their transactions"
|
||||||
|
|
||||||
|
@scopes_disabled()
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
qs = Order.objects.annotate(
|
||||||
|
position_total=Coalesce(
|
||||||
|
Subquery(
|
||||||
|
OrderPosition.objects.filter(
|
||||||
|
order=OuterRef('pk')
|
||||||
|
).order_by().values('order').annotate(p=Sum('price')).values('p'),
|
||||||
|
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
),
|
||||||
|
position_cnt=Case(
|
||||||
|
When(Q(status__in=('e', 'c')) | Q(require_approval=True), then=Value(0)),
|
||||||
|
default=Coalesce(
|
||||||
|
Subquery(
|
||||||
|
OrderPosition.objects.filter(
|
||||||
|
order=OuterRef('pk')
|
||||||
|
).order_by().values('order').annotate(p=Count('*')).values('p'),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
), Value(0), output_field=models.IntegerField()
|
||||||
|
),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
),
|
||||||
|
fee_total=Coalesce(
|
||||||
|
Subquery(
|
||||||
|
OrderFee.objects.filter(
|
||||||
|
order=OuterRef('pk')
|
||||||
|
).order_by().values('order').annotate(p=Sum('value')).values('p'),
|
||||||
|
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
),
|
||||||
|
tx_total=Coalesce(
|
||||||
|
Subquery(
|
||||||
|
Transaction.objects.filter(
|
||||||
|
order=OuterRef('pk')
|
||||||
|
).order_by().values('order').annotate(p=Sum(F('price') * F('count'))).values('p'),
|
||||||
|
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
),
|
||||||
|
tx_cnt=Coalesce(
|
||||||
|
Subquery(
|
||||||
|
Transaction.objects.filter(
|
||||||
|
order=OuterRef('pk'),
|
||||||
|
item__isnull=False,
|
||||||
|
).order_by().values('order').annotate(p=Sum(F('count'))).values('p'),
|
||||||
|
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
),
|
||||||
|
).annotate(
|
||||||
|
correct_total=Case(
|
||||||
|
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
|
||||||
|
then=Value(0)),
|
||||||
|
default=F('position_total') + F('fee_total'),
|
||||||
|
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
),
|
||||||
|
).exclude(
|
||||||
|
total=F('position_total') + F('fee_total'),
|
||||||
|
tx_total=F('correct_total'),
|
||||||
|
tx_cnt=F('position_cnt')
|
||||||
|
).select_related('event')
|
||||||
|
for o in qs:
|
||||||
|
if abs(o.tx_total - o.correct_total) < Decimal('0.00001') and abs(o.position_total + o.fee_total - o.total) < Decimal('0.00001') \
|
||||||
|
and o.tx_cnt == o.position_cnt:
|
||||||
|
# Ignore SQLite which treats Decimals like floats…
|
||||||
|
continue
|
||||||
|
print(f"Error in order {o.full_code}: status={o.status}, sum(positions)+sum(fees)={o.position_total + o.fee_total}, "
|
||||||
|
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}, pos_cnt={o.position_cnt}, tx_pos_cnt={o.tx_cnt}")
|
||||||
|
|
||||||
|
self.stderr.write(self.style.SUCCESS(f'Check completed.'))
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db.models import F, Max, Q
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
from pretix.base.models import Order
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Create missing order transactions"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--slowdown",
|
||||||
|
dest="interval",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="Interval for staggered execution. If set to a value different then zero, we will "
|
||||||
|
"wait this many milliseconds between every order we process.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@scopes_disabled()
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
t = 0
|
||||||
|
qs = Order.objects.annotate(
|
||||||
|
last_transaction=Max('transactions__created')
|
||||||
|
).filter(
|
||||||
|
Q(last_transaction__isnull=True) | Q(last_modified__gt=F('last_transaction')),
|
||||||
|
require_approval=False,
|
||||||
|
).prefetch_related(
|
||||||
|
'all_positions', 'all_fees'
|
||||||
|
).order_by(
|
||||||
|
'pk'
|
||||||
|
)
|
||||||
|
last_pk = 0
|
||||||
|
with tqdm(total=qs.count()) as pbar:
|
||||||
|
while True:
|
||||||
|
batch = list(qs.filter(pk__gt=last_pk)[:5000])
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
|
||||||
|
for o in batch:
|
||||||
|
if o.last_transaction is None:
|
||||||
|
tn = o.create_transactions(
|
||||||
|
positions=o.all_positions.all(),
|
||||||
|
fees=o.all_fees.all(),
|
||||||
|
dt_now=o.datetime,
|
||||||
|
migrated=True,
|
||||||
|
is_new=True,
|
||||||
|
_backfill_before_cancellation=True,
|
||||||
|
)
|
||||||
|
o.create_transactions(
|
||||||
|
positions=o.all_positions.all(),
|
||||||
|
fees=o.all_fees.all(),
|
||||||
|
dt_now=o.cancellation_date or (o.expires if o.status == Order.STATUS_EXPIRED else o.datetime),
|
||||||
|
migrated=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tn = o.create_transactions(
|
||||||
|
positions=o.all_positions.all(),
|
||||||
|
fees=o.all_fees.all(),
|
||||||
|
dt_now=now(),
|
||||||
|
migrated=True,
|
||||||
|
)
|
||||||
|
if tn:
|
||||||
|
t += 1
|
||||||
|
time.sleep(0)
|
||||||
|
pbar.update(1)
|
||||||
|
last_pk = batch[-1].pk
|
||||||
|
|
||||||
|
self.stderr.write(self.style.SUCCESS(f'Created transactions for {t} orders.'))
|
||||||
@@ -103,7 +103,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
with language(locale), override(timezone):
|
with language(locale), override(timezone):
|
||||||
for receiver, response in signal_result:
|
for receiver, response in signal_result:
|
||||||
ex = response(e, report_status)
|
ex = response(e, o, report_status)
|
||||||
if ex.identifier == options['export_provider']:
|
if ex.identifier == options['export_provider']:
|
||||||
params = json.loads(options.get('parameters') or '{}')
|
params = json.loads(options.get('parameters') or '{}')
|
||||||
with open(options['output_file'], 'wb') as f:
|
with open(options['output_file'], 'wb') as f:
|
||||||
|
|||||||
@@ -32,53 +32,11 @@
|
|||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
"""
|
|
||||||
Django, for theoretically very valid reasons, creates migrations for *every single thing*
|
|
||||||
we change on a model. Even the `help_text`! This makes sense, as we don't know if any
|
|
||||||
database backend unknown to us might actually use this information for its database schema.
|
|
||||||
|
|
||||||
However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
|
|
||||||
certain that some changes to models will never require a change to the database. In this case,
|
|
||||||
not creating a migration for certain changes will save us some performance while applying them
|
|
||||||
*and* allow for a cleaner git history. Win-win!
|
|
||||||
|
|
||||||
Only caveat is that we need to do some dirty monkeypatching to achieve it...
|
|
||||||
"""
|
|
||||||
from django.core.management.commands.makemigrations import Command as Parent
|
from django.core.management.commands.makemigrations import Command as Parent
|
||||||
from django.db import models
|
|
||||||
from django.db.migrations.operations import models as modelops
|
|
||||||
from django_countries.fields import CountryField
|
|
||||||
|
|
||||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
|
from ._migrations import monkeypatch_migrations
|
||||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
|
|
||||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
|
|
||||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
|
|
||||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
|
|
||||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
|
|
||||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
|
|
||||||
IGNORED_ATTRS = [
|
|
||||||
# (field type, attribute name, banlist of field sub-types)
|
|
||||||
(models.Field, 'verbose_name', []),
|
|
||||||
(models.Field, 'help_text', []),
|
|
||||||
(models.Field, 'validators', []),
|
|
||||||
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
|
|
||||||
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
|
|
||||||
models.TimeField]),
|
|
||||||
(models.CharField, 'choices', [CountryField])
|
|
||||||
]
|
|
||||||
|
|
||||||
original_deconstruct = models.Field.deconstruct
|
monkeypatch_migrations()
|
||||||
|
|
||||||
|
|
||||||
def new_deconstruct(self):
|
|
||||||
name, path, args, kwargs = original_deconstruct(self)
|
|
||||||
for ftype, attr, banlist in IGNORED_ATTRS:
|
|
||||||
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in banlist):
|
|
||||||
kwargs.pop(attr, None)
|
|
||||||
return name, path, args, kwargs
|
|
||||||
|
|
||||||
|
|
||||||
models.Field.deconstruct = new_deconstruct
|
|
||||||
|
|
||||||
|
|
||||||
class Command(Parent):
|
class Command(Parent):
|
||||||
|
|||||||
@@ -32,12 +32,6 @@
|
|||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
"""
|
|
||||||
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
|
|
||||||
run when there are things we have no migrations for. Usually, this is intended, and running
|
|
||||||
"makemigrations" can really screw up the environment of a user, so we want to prevent novice
|
|
||||||
users from doing that by going really dirty and filtering it from the output.
|
|
||||||
"""
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from django.core.management.base import OutputWrapper
|
from django.core.management.base import OutputWrapper
|
||||||
@@ -45,9 +39,15 @@ from django.core.management.commands.migrate import Command as Parent
|
|||||||
|
|
||||||
|
|
||||||
class OutputFilter(OutputWrapper):
|
class OutputFilter(OutputWrapper):
|
||||||
|
"""
|
||||||
|
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
|
||||||
|
run when there are things we have no migrations for. Usually, this is intended, and running
|
||||||
|
"makemigrations" can really screw up the environment of a user, so we want to prevent novice
|
||||||
|
users from doing that by going really dirty and filtering it from the output.
|
||||||
|
"""
|
||||||
banlist = (
|
banlist = (
|
||||||
"Your models have changes that are not yet reflected",
|
"have changes that are not yet reflected",
|
||||||
"Run 'manage.py makemigrations' to make new "
|
"re-run 'manage.py migrate'"
|
||||||
)
|
)
|
||||||
|
|
||||||
def write(self, msg, style_func=None, ending=None):
|
def write(self, msg, style_func=None, ending=None):
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ def _parse_csp(header):
|
|||||||
|
|
||||||
|
|
||||||
def _render_csp(h):
|
def _render_csp(h):
|
||||||
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items())
|
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v)
|
||||||
|
|
||||||
|
|
||||||
def _merge_csp(a, b):
|
def _merge_csp(a, b):
|
||||||
|
|||||||
38
src/pretix/base/migrations/0196_auto_20210523_1322.py
Normal file
38
src/pretix/base/migrations/0196_auto_20210523_1322.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 3.2.2 on 2021-05-23 13:22
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import pretix.helpers.countries
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0195_auto_20210622_1457'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoiceaddress',
|
||||||
|
name='customer',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invoice_addresses', to='pretixbase.customer'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AttendeeProfile',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||||
|
('attendee_name_cached', models.CharField(max_length=255, null=True)),
|
||||||
|
('attendee_name_parts', models.JSONField(default=dict)),
|
||||||
|
('attendee_email', models.EmailField(max_length=254, null=True)),
|
||||||
|
('company', models.CharField(max_length=255, null=True)),
|
||||||
|
('street', models.TextField(null=True)),
|
||||||
|
('zipcode', models.CharField(max_length=30, null=True)),
|
||||||
|
('city', models.CharField(max_length=255, null=True)),
|
||||||
|
('country', pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True)),
|
||||||
|
('state', models.CharField(max_length=255, null=True)),
|
||||||
|
('answers', models.JSONField(default=list)),
|
||||||
|
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendee_profiles', to='pretixbase.customer')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
36
src/pretix/base/migrations/0197_auto_20210914_0814.py
Normal file
36
src/pretix/base/migrations/0197_auto_20210914_0814.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-09-14 08:14
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import pretix.base.models.fields
|
||||||
|
import pretix.base.models.items
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0196_auto_20210523_1322'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='itemvariation',
|
||||||
|
name='available_from',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='itemvariation',
|
||||||
|
name='available_until',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='itemvariation',
|
||||||
|
name='hide_without_voucher',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='itemvariation',
|
||||||
|
name='sales_channels',
|
||||||
|
field=pretix.base.models.fields.MultiStringField(default=pretix.base.models.items._all_sales_channels_identifiers),
|
||||||
|
),
|
||||||
|
]
|
||||||
21
src/pretix/base/migrations/0198_invoice_sent_to_customer.py
Normal file
21
src/pretix/base/migrations/0198_invoice_sent_to_customer.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-09-30 10:25
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
from pytz import UTC
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0197_auto_20210914_0814'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoice',
|
||||||
|
name='sent_to_customer',
|
||||||
|
field=models.DateTimeField(blank=True, null=True, default=UTC.localize(datetime(1970, 1, 1, 0, 0, 0, 0))),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
23
src/pretix/base/migrations/0199_auto_20211005_1050.py
Normal file
23
src/pretix/base/migrations/0199_auto_20211005_1050.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-10-05 10:50
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0198_invoice_sent_to_customer'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='require_membership_hidden',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='itemvariation',
|
||||||
|
name='require_membership_hidden',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
38
src/pretix/base/migrations/0200_transaction.py
Normal file
38
src/pretix/base/migrations/0200_transaction.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-10-18 10:27
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0199_auto_20211005_1050'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Transaction',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('datetime', models.DateTimeField(db_index=True)),
|
||||||
|
('migrated', models.BooleanField(default=False)),
|
||||||
|
('positionid', models.PositiveIntegerField(default=1, null=True)),
|
||||||
|
('count', models.IntegerField(default=1)),
|
||||||
|
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('tax_rate', models.DecimalField(decimal_places=2, max_digits=7)),
|
||||||
|
('tax_value', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('fee_type', models.CharField(max_length=100, null=True)),
|
||||||
|
('internal_type', models.CharField(max_length=255, null=True)),
|
||||||
|
('item', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.item')),
|
||||||
|
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='pretixbase.order')),
|
||||||
|
('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.subevent')),
|
||||||
|
('tax_rule', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.taxrule')),
|
||||||
|
('variation', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.itemvariation')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('datetime', 'pk'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2021-11-03 09:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0200_transaction'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoiceline',
|
||||||
|
name='event_location',
|
||||||
|
field=models.TextField(null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.9 on 2021-11-04 13:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0201_invoiceline_event_location'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='needs_password_change',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
17
src/pretix/base/migrations/0203_orderposition_is_bundled.py
Normal file
17
src/pretix/base/migrations/0203_orderposition_is_bundled.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 3.2.2 on 2021-11-08 07:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0202_user_needs_password_change'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderposition',
|
||||||
|
name='is_bundled',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 3.2.2 on 2021-11-08 07:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.models import Count, OuterRef, Subquery
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
|
|
||||||
|
def fill_is_bundled(apps, schema_editor):
|
||||||
|
# We cannot really know if a position was bundled or an add-on, but we can at least guess
|
||||||
|
ItemBundle = apps.get_model("pretixbase", "ItemBundle")
|
||||||
|
OrderPosition = apps.get_model("pretixbase", "OrderPosition")
|
||||||
|
|
||||||
|
for ib in ItemBundle.objects.iterator():
|
||||||
|
OrderPosition.all.alias(
|
||||||
|
pos_earlier=Coalesce(Subquery(
|
||||||
|
OrderPosition.all.filter(
|
||||||
|
canceled=False,
|
||||||
|
addon_to=OuterRef('addon_to'),
|
||||||
|
item=ib.bundled_item,
|
||||||
|
variation=ib.bundled_variation,
|
||||||
|
positionid__lt=OuterRef('positionid'),
|
||||||
|
).values('addon_to').order_by().annotate(c=Count('*')).values('c'),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
), 0)
|
||||||
|
).filter(
|
||||||
|
canceled=False,
|
||||||
|
addon_to__item=ib.base_item,
|
||||||
|
item=ib.bundled_item,
|
||||||
|
variation=ib.bundled_variation,
|
||||||
|
pos_earlier__lt=ib.count,
|
||||||
|
).update(
|
||||||
|
is_bundled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0203_orderposition_is_bundled'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
fill_is_bundled,
|
||||||
|
migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.9 on 2021-12-13 14:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0204_orderposition_backfill_is_bundled'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='itemvariation',
|
||||||
|
name='require_approval',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
src/pretix/base/migrations/0206_customer_phone.py
Normal file
19
src/pretix/base/migrations/0206_customer_phone.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 3.2.9 on 2022-01-12 10:59
|
||||||
|
|
||||||
|
import phonenumber_field.modelfields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0205_itemvariation_require_approval'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customer',
|
||||||
|
name='phone',
|
||||||
|
field=phonenumber_field.modelfields.PhoneNumberField(max_length=128, null=True, region=None),
|
||||||
|
),
|
||||||
|
]
|
||||||
23
src/pretix/base/migrations/0207_auto_20220119_1427.py
Normal file
23
src/pretix/base/migrations/0207_auto_20220119_1427.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2022-01-19 14:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0206_customer_phone'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='taxrule',
|
||||||
|
name='internal_name',
|
||||||
|
field=models.CharField(max_length=190, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='taxrule',
|
||||||
|
name='keep_gross_if_rate_changes',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -42,8 +42,9 @@ from .notifications import NotificationSetting
|
|||||||
from .orders import (
|
from .orders import (
|
||||||
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
|
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
|
||||||
InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund,
|
InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund,
|
||||||
QuestionAnswer, RevokedTicketSecret, cachedcombinedticket_name,
|
QuestionAnswer, RevokedTicketSecret, Transaction,
|
||||||
cachedticket_name, generate_position_secret, generate_secret,
|
cachedcombinedticket_name, cachedticket_name, generate_position_secret,
|
||||||
|
generate_secret,
|
||||||
)
|
)
|
||||||
from .organizer import (
|
from .organizer import (
|
||||||
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
|
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
|
||||||
|
|||||||
113
src/pretix/base/models/_transactions.py
Normal file
113
src/pretix/base/models/_transactions.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
This module contains helper functions that are supposed to call out code paths missing calls to
|
||||||
|
``Order.create_transaction()`` by actively breaking them. Read the docstring of the ``Transaction`` class for a
|
||||||
|
detailed reasoning why this exists.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
dirty_transactions = threading.local()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
fail_loudly = os.getenv('PRETIX_DIRTY_TRANSACTIONS_QUIET', 'false' if settings.DEBUG else 'true') not in ('true', 'True', 'on', '1')
|
||||||
|
|
||||||
|
|
||||||
|
class DirtyTransactionsForOrderException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(message):
|
||||||
|
if fail_loudly:
|
||||||
|
raise DirtyTransactionsForOrderException(message)
|
||||||
|
else:
|
||||||
|
if settings.SENTRY_ENABLED:
|
||||||
|
import sentry_sdk
|
||||||
|
|
||||||
|
sentry_sdk.capture_message(message, "fatal")
|
||||||
|
|
||||||
|
logger.warning(message, stack_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_for_dirty_orders():
|
||||||
|
if getattr(dirty_transactions, 'order_ids', None) is None:
|
||||||
|
dirty_transactions.order_ids = set()
|
||||||
|
try:
|
||||||
|
if dirty_transactions.order_ids and dirty_transactions.order_ids != {None}:
|
||||||
|
_fail(
|
||||||
|
f"In the transaction that just ended, you created or modified an Order, OrderPosition, or OrderFee "
|
||||||
|
f"object in a way that you should have called `order.create_transactions()` afterwards. The transaction "
|
||||||
|
f"still went through and your data can be fixed with the `create_order_transactions` management command "
|
||||||
|
f"but you should update your code to prevent this from happening. Affected order IDs: {dirty_transactions.order_ids}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
dirty_transactions.order_ids.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _transactions_mark_order_dirty(order_id, using=None):
|
||||||
|
if "PYTEST_CURRENT_TEST" in os.environ:
|
||||||
|
# We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code
|
||||||
|
# or not.
|
||||||
|
for frame in inspect.stack():
|
||||||
|
if 'pretix/base/models/orders' in frame.filename:
|
||||||
|
continue
|
||||||
|
elif 'test_' in frame.filename or 'conftest.py in frame.filename':
|
||||||
|
return
|
||||||
|
elif 'pretix/' in frame.filename or 'pretix_' in frame.filename:
|
||||||
|
# This went through non-test code, let's consider it non-test
|
||||||
|
break
|
||||||
|
|
||||||
|
if order_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = transaction.get_connection(using)
|
||||||
|
if not conn.in_atomic_block:
|
||||||
|
_fail(
|
||||||
|
"You modified an Order, OrderPosition, or OrderFee object in a way that should create "
|
||||||
|
"a new Transaction object within the same database transaction, however you are not "
|
||||||
|
"doing it inside a database transaction!"
|
||||||
|
)
|
||||||
|
|
||||||
|
if getattr(dirty_transactions, 'order_ids', None) is None:
|
||||||
|
dirty_transactions.order_ids = set()
|
||||||
|
|
||||||
|
if _check_for_dirty_orders not in [func for savepoint_id, func in conn.run_on_commit]:
|
||||||
|
transaction.on_commit(_check_for_dirty_orders, using)
|
||||||
|
dirty_transactions.order_ids.clear() # This is necessary to clean up after old threads with rollbacked transactions
|
||||||
|
|
||||||
|
dirty_transactions.order_ids.add(order_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _transactions_mark_order_clean(order_id):
|
||||||
|
if getattr(dirty_transactions, 'order_ids', None) is None:
|
||||||
|
dirty_transactions.order_ids = set()
|
||||||
|
try:
|
||||||
|
dirty_transactions.order_ids.remove(order_id)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
@@ -113,6 +113,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
:type date_joined: datetime
|
:type date_joined: datetime
|
||||||
:param locale: The user's preferred locale code.
|
:param locale: The user's preferred locale code.
|
||||||
:type locale: str
|
:type locale: str
|
||||||
|
:param needs_password_change: Whether this user's password needs to be changed.
|
||||||
|
:type needs_password_change: bool
|
||||||
:param timezone: The user's preferred timezone.
|
:param timezone: The user's preferred timezone.
|
||||||
:type timezone: str
|
:type timezone: str
|
||||||
"""
|
"""
|
||||||
@@ -130,6 +132,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
verbose_name=_('Is site admin'))
|
verbose_name=_('Is site admin'))
|
||||||
date_joined = models.DateTimeField(auto_now_add=True,
|
date_joined = models.DateTimeField(auto_now_add=True,
|
||||||
verbose_name=_('Date joined'))
|
verbose_name=_('Date joined'))
|
||||||
|
needs_password_change = models.BooleanField(default=False,
|
||||||
|
verbose_name=_('Force user to select a new password'))
|
||||||
locale = models.CharField(max_length=50,
|
locale = models.CharField(max_length=50,
|
||||||
choices=settings.LANGUAGES,
|
choices=settings.LANGUAGES,
|
||||||
default=settings.LANGUAGE_CODE,
|
default=settings.LANGUAGE_CODE,
|
||||||
|
|||||||
@@ -19,19 +19,23 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import pycountry
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import (
|
from django.contrib.auth.hashers import (
|
||||||
check_password, is_password_usable, make_password,
|
check_password, is_password_usable, make_password,
|
||||||
)
|
)
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import F, Q
|
||||||
from django.utils.crypto import get_random_string, salted_hmac
|
from django.utils.crypto import get_random_string, salted_hmac
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||||
from django_scopes import ScopedManager, scopes_disabled
|
from django_scopes import ScopedManager, scopes_disabled
|
||||||
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
|
|
||||||
from pretix.base.banlist import banned
|
from pretix.base.banlist import banned
|
||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
from pretix.base.models.organizer import Organizer
|
from pretix.base.models.organizer import Organizer
|
||||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||||
|
from pretix.helpers.countries import FastCountryField
|
||||||
|
|
||||||
|
|
||||||
class Customer(LoggedModel):
|
class Customer(LoggedModel):
|
||||||
@@ -42,6 +46,7 @@ class Customer(LoggedModel):
|
|||||||
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
||||||
identifier = models.CharField(max_length=190, db_index=True, unique=True)
|
identifier = models.CharField(max_length=190, db_index=True, unique=True)
|
||||||
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
|
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
|
||||||
|
phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number'))
|
||||||
password = models.CharField(verbose_name=_('Password'), max_length=128)
|
password = models.CharField(verbose_name=_('Password'), max_length=128)
|
||||||
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||||
name_parts = models.JSONField(default=dict)
|
name_parts = models.JSONField(default=dict)
|
||||||
@@ -84,10 +89,13 @@ class Customer(LoggedModel):
|
|||||||
self.name_parts = {}
|
self.name_parts = {}
|
||||||
self.name_cached = ''
|
self.name_cached = ''
|
||||||
self.email = None
|
self.email = None
|
||||||
|
self.phone = None
|
||||||
self.save()
|
self.save()
|
||||||
self.all_logentries().update(data={}, shredded=True)
|
self.all_logentries().update(data={}, shredded=True)
|
||||||
self.orders.all().update(customer=None)
|
self.orders.all().update(customer=None)
|
||||||
self.memberships.all().update(attendee_name_parts=None)
|
self.memberships.all().update(attendee_name_parts=None)
|
||||||
|
self.attendee_profiles.all().delete()
|
||||||
|
self.invoice_addresses.all().delete()
|
||||||
|
|
||||||
@scopes_disabled()
|
@scopes_disabled()
|
||||||
def assign_identifier(self):
|
def assign_identifier(self):
|
||||||
@@ -174,3 +182,94 @@ class Customer(LoggedModel):
|
|||||||
continue
|
continue
|
||||||
ctx['name_%s' % f] = self.name_parts.get(f, '')
|
ctx['name_%s' % f] = self.name_parts.get(f, '')
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stored_addresses(self):
|
||||||
|
return self.invoice_addresses(manager='profiles')
|
||||||
|
|
||||||
|
def usable_memberships(self, for_event, testmode=False):
|
||||||
|
return self.memberships.active(for_event).with_usages().filter(
|
||||||
|
Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages')),
|
||||||
|
testmode=testmode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AttendeeProfile(models.Model):
|
||||||
|
customer = models.ForeignKey(
|
||||||
|
Customer,
|
||||||
|
related_name='attendee_profiles',
|
||||||
|
on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
attendee_name_cached = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("Attendee name"),
|
||||||
|
blank=True, null=True,
|
||||||
|
)
|
||||||
|
attendee_name_parts = models.JSONField(
|
||||||
|
blank=True, default=dict
|
||||||
|
)
|
||||||
|
attendee_email = models.EmailField(
|
||||||
|
verbose_name=_("Attendee email"),
|
||||||
|
blank=True, null=True,
|
||||||
|
)
|
||||||
|
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True)
|
||||||
|
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
|
||||||
|
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
|
||||||
|
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True, null=True)
|
||||||
|
country = FastCountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
|
||||||
|
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True, null=True)
|
||||||
|
answers = models.JSONField(default=list)
|
||||||
|
|
||||||
|
objects = ScopedManager(organizer='customer__organizer')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attendee_name(self):
|
||||||
|
if not self.attendee_name_parts:
|
||||||
|
return None
|
||||||
|
if '_legacy' in self.attendee_name_parts:
|
||||||
|
return self.attendee_name_parts['_legacy']
|
||||||
|
if '_scheme' in self.attendee_name_parts:
|
||||||
|
scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
|
||||||
|
else:
|
||||||
|
scheme = PERSON_NAME_SCHEMES[self.customer.organizer.settings.name_scheme]
|
||||||
|
return scheme['concatenation'](self.attendee_name_parts).strip()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_name(self):
|
||||||
|
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||||
|
if sd:
|
||||||
|
return sd.name
|
||||||
|
return self.state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_for_address(self):
|
||||||
|
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||||
|
if not self.state or str(self.country) not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||||
|
return ""
|
||||||
|
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.country)][1] == 'long':
|
||||||
|
return self.state_name
|
||||||
|
return self.state
|
||||||
|
|
||||||
|
def describe(self):
|
||||||
|
from .items import Question
|
||||||
|
from .orders import QuestionAnswer
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
self.attendee_name,
|
||||||
|
self.attendee_email,
|
||||||
|
self.company,
|
||||||
|
self.street,
|
||||||
|
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||||
|
self.country.name,
|
||||||
|
]
|
||||||
|
for a in self.answers:
|
||||||
|
value = a.get('value')
|
||||||
|
try:
|
||||||
|
value = ", ".join(value.values())
|
||||||
|
except AttributeError:
|
||||||
|
value = str(value)
|
||||||
|
answer = QuestionAnswer(question=Question(type=a.get('question_type')), answer=value)
|
||||||
|
val = str(answer)
|
||||||
|
parts.append(f'{a["field_label"]}: {val}')
|
||||||
|
|
||||||
|
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
|
||||||
|
import logging
|
||||||
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
||||||
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||||
#
|
#
|
||||||
@@ -56,6 +57,7 @@ from django.urls import reverse
|
|||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
from django.utils.html import format_html
|
||||||
from django.utils.timezone import make_aware, now
|
from django.utils.timezone import make_aware, now
|
||||||
from django.utils.translation import gettext, gettext_lazy as _
|
from django.utils.translation import gettext, gettext_lazy as _
|
||||||
from django_scopes import ScopedManager, scopes_disabled
|
from django_scopes import ScopedManager, scopes_disabled
|
||||||
@@ -73,6 +75,8 @@ from pretix.helpers.thumb import get_thumbnail
|
|||||||
from ..settings import settings_hierarkey
|
from ..settings import settings_hierarkey
|
||||||
from .organizer import Organizer, Team
|
from .organizer import Organizer, Team
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class EventMixin:
|
class EventMixin:
|
||||||
def clean(self):
|
def clean(self):
|
||||||
@@ -142,7 +146,7 @@ class EventMixin:
|
|||||||
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT")
|
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT")
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_date_range_display(self, tz=None, force_show_end=False) -> str:
|
def get_date_range_display(self, tz=None, force_show_end=False, as_html=False) -> str:
|
||||||
"""
|
"""
|
||||||
Returns a formatted string containing the start date and the end date
|
Returns a formatted string containing the start date and the end date
|
||||||
of the event with respect to the current locale and to the ``show_date_to``
|
of the event with respect to the current locale and to the ``show_date_to``
|
||||||
@@ -150,8 +154,40 @@ class EventMixin:
|
|||||||
"""
|
"""
|
||||||
tz = tz or self.timezone
|
tz = tz or self.timezone
|
||||||
if (not self.settings.show_date_to and not force_show_end) or not self.date_to:
|
if (not self.settings.show_date_to and not force_show_end) or not self.date_to:
|
||||||
|
if as_html:
|
||||||
|
return format_html(
|
||||||
|
"<time datetime=\"{}\">{}</time>",
|
||||||
|
_date(self.date_from.astimezone(tz), "Y-m-d"),
|
||||||
|
_date(self.date_from.astimezone(tz), "DATE_FORMAT"),
|
||||||
|
)
|
||||||
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
|
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
|
||||||
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
|
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz), as_html)
|
||||||
|
|
||||||
|
def get_date_range_display_as_html(self, tz=None, force_show_end=False) -> str:
|
||||||
|
return self.get_date_range_display(tz, force_show_end, as_html=True)
|
||||||
|
|
||||||
|
def get_time_range_display(self, tz=None, force_show_end=False) -> str:
|
||||||
|
"""
|
||||||
|
Returns a formatted string containing the start time and sometimes the end time
|
||||||
|
of the event with respect to the current locale and to the ``show_date_to``
|
||||||
|
setting. Dates are not shown. This is usually used in combination with get_date_range_display
|
||||||
|
"""
|
||||||
|
tz = tz or self.timezone
|
||||||
|
|
||||||
|
show_date_to = self.date_to and (self.settings.show_date_to or force_show_end) and (
|
||||||
|
# Show date to if start and end are on the same day ("08:00-10:00")
|
||||||
|
self.date_to.astimezone(tz).date() == self.date_from.astimezone(tz).date() or
|
||||||
|
# Show date to if start and end are on consecutive days and less than 24h ("23:00-03:00")
|
||||||
|
(self.date_to.astimezone(tz).date() == self.date_from.astimezone(tz).date() + timedelta(days=1) and
|
||||||
|
self.date_to.astimezone(tz).time() < self.date_from.astimezone(tz).time())
|
||||||
|
# Do not show end time if this is a 5-day event because there's no way to make it understandable
|
||||||
|
)
|
||||||
|
if show_date_to:
|
||||||
|
return '{} – {}'.format(
|
||||||
|
_date(self.date_from.astimezone(tz), "TIME_FORMAT"),
|
||||||
|
_date(self.date_to.astimezone(tz), "TIME_FORMAT"),
|
||||||
|
)
|
||||||
|
return _date(self.date_from.astimezone(tz), "TIME_FORMAT")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def timezone(self):
|
def timezone(self):
|
||||||
@@ -242,6 +278,10 @@ class EventMixin:
|
|||||||
).values('items')
|
).values('items')
|
||||||
sq_active_variation = ItemVariation.objects.filter(
|
sq_active_variation = ItemVariation.objects.filter(
|
||||||
Q(active=True)
|
Q(active=True)
|
||||||
|
& Q(sales_channels__contains=channel)
|
||||||
|
& Q(hide_without_voucher=False)
|
||||||
|
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||||
|
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||||
& Q(item__active=True)
|
& Q(item__active=True)
|
||||||
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
|
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
|
||||||
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
|
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
|
||||||
@@ -492,7 +532,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
default=False
|
default=False
|
||||||
)
|
)
|
||||||
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
||||||
related_name='events')
|
related_name='events', verbose_name=_('Seating plan'))
|
||||||
|
|
||||||
last_modified = models.DateTimeField(
|
last_modified = models.DateTimeField(
|
||||||
auto_now=True, db_index=True
|
auto_now=True, db_index=True
|
||||||
@@ -525,6 +565,8 @@ class Event(EventMixin, LoggedModel):
|
|||||||
self.settings.ticketoutput_pdf__enabled = True
|
self.settings.ticketoutput_pdf__enabled = True
|
||||||
self.settings.ticketoutput_passbook__enabled = True
|
self.settings.ticketoutput_passbook__enabled = True
|
||||||
self.settings.event_list_type = 'calendar'
|
self.settings.event_list_type = 'calendar'
|
||||||
|
self.settings.invoice_email_attachment = True
|
||||||
|
self.settings.name_scheme = 'given_family'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def social_image(self):
|
def social_image(self):
|
||||||
@@ -534,9 +576,17 @@ class Event(EventMixin, LoggedModel):
|
|||||||
logo_file = self.settings.get('logo_image', as_type=str, default='')[7:]
|
logo_file = self.settings.get('logo_image', as_type=str, default='')[7:]
|
||||||
og_file = self.settings.get('og_image', as_type=str, default='')[7:]
|
og_file = self.settings.get('og_image', as_type=str, default='')[7:]
|
||||||
if og_file:
|
if og_file:
|
||||||
img = get_thumbnail(og_file, '1200').thumb.url
|
try:
|
||||||
|
img = get_thumbnail(og_file, '1200').thumb.url
|
||||||
|
except:
|
||||||
|
logger.exception(f'Failed to create thumbnail of {og_file}')
|
||||||
|
img = default_storage.url(og_file)
|
||||||
elif logo_file:
|
elif logo_file:
|
||||||
img = get_thumbnail(logo_file, '5000x120').thumb.url
|
try:
|
||||||
|
img = get_thumbnail(logo_file, '5000x1200').thumb.url
|
||||||
|
except:
|
||||||
|
logger.exception(f'Failed to create thumbnail of {logo_file}')
|
||||||
|
img = default_storage.url(logo_file)
|
||||||
if img:
|
if img:
|
||||||
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
|
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
|
||||||
|
|
||||||
@@ -615,21 +665,22 @@ class Event(EventMixin, LoggedModel):
|
|||||||
|
|
||||||
return locking.LockManager(self)
|
return locking.LockManager(self)
|
||||||
|
|
||||||
def get_mail_backend(self, timeout=None, force_custom=False):
|
def get_mail_backend(self, timeout=None):
|
||||||
"""
|
"""
|
||||||
Returns an email server connection, either by using the system-wide connection
|
Returns an email server connection, either by using the system-wide connection
|
||||||
or by returning a custom one based on the event's settings.
|
or by returning a custom one based on the event's settings.
|
||||||
"""
|
"""
|
||||||
from pretix.base.email import CustomSMTPBackend
|
|
||||||
|
|
||||||
if self.settings.smtp_use_custom or force_custom:
|
if self.settings.smtp_use_custom:
|
||||||
return CustomSMTPBackend(host=self.settings.smtp_host,
|
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||||
port=self.settings.smtp_port,
|
host=self.settings.smtp_host,
|
||||||
username=self.settings.smtp_username,
|
port=self.settings.smtp_port,
|
||||||
password=self.settings.smtp_password,
|
username=self.settings.smtp_username,
|
||||||
use_tls=self.settings.smtp_use_tls,
|
password=self.settings.smtp_password,
|
||||||
use_ssl=self.settings.smtp_use_ssl,
|
use_tls=self.settings.smtp_use_tls,
|
||||||
fail_silently=False, timeout=timeout)
|
use_ssl=self.settings.smtp_use_ssl,
|
||||||
|
fail_silently=False,
|
||||||
|
timeout=timeout)
|
||||||
else:
|
else:
|
||||||
return get_connection(fail_silently=False)
|
return get_connection(fail_silently=False)
|
||||||
|
|
||||||
@@ -741,7 +792,9 @@ class Event(EventMixin, LoggedModel):
|
|||||||
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
|
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
|
||||||
ia.save()
|
ia.save()
|
||||||
|
|
||||||
|
quota_map = {}
|
||||||
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
|
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
|
||||||
|
quota_map[q.pk] = q
|
||||||
items = list(q.items.all())
|
items = list(q.items.all())
|
||||||
vars = list(q.variations.all())
|
vars = list(q.variations.all())
|
||||||
oldid = q.pk
|
oldid = q.pk
|
||||||
@@ -865,7 +918,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
event_copy_data.send(
|
event_copy_data.send(
|
||||||
sender=self, other=other,
|
sender=self, other=other,
|
||||||
tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map,
|
tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map,
|
||||||
question_map=question_map, checkin_list_map=checkin_list_map
|
question_map=question_map, checkin_list_map=checkin_list_map, quota_map=quota_map,
|
||||||
)
|
)
|
||||||
|
|
||||||
if has_custom_style:
|
if has_custom_style:
|
||||||
@@ -1004,6 +1057,11 @@ class Event(EventMixin, LoggedModel):
|
|||||||
| Q(date_to__gte=now() - timedelta(hours=24))
|
| Q(date_to__gte=now() - timedelta(hours=24))
|
||||||
)
|
)
|
||||||
) # order_by doesn't make sense with I18nField
|
) # order_by doesn't make sense with I18nField
|
||||||
|
if ordering in ("date_ascending", "date_descending"):
|
||||||
|
# if primary order is by date, then order in database
|
||||||
|
# this allows to limit/slice results
|
||||||
|
return subevs.order_by(*orderfields)
|
||||||
|
|
||||||
for f in reversed(orderfields):
|
for f in reversed(orderfields):
|
||||||
if f.startswith('-'):
|
if f.startswith('-'):
|
||||||
subevs = sorted(subevs, key=attrgetter(f[1:]), reverse=True)
|
subevs = sorted(subevs, key=attrgetter(f[1:]), reverse=True)
|
||||||
@@ -1113,15 +1171,18 @@ class Event(EventMixin, LoggedModel):
|
|||||||
self.items.all().delete()
|
self.items.all().delete()
|
||||||
self.subevents.all().delete()
|
self.subevents.all().delete()
|
||||||
|
|
||||||
def set_active_plugins(self, modules, allow_restricted=False):
|
def get_available_plugins(self):
|
||||||
from pretix.base.plugins import get_all_plugins
|
from pretix.base.plugins import get_all_plugins
|
||||||
|
|
||||||
plugins_active = self.get_plugins()
|
return {
|
||||||
plugins_available = {
|
|
||||||
p.module: p for p in get_all_plugins(self)
|
p.module: p for p in get_all_plugins(self)
|
||||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def set_active_plugins(self, modules, allow_restricted=False):
|
||||||
|
plugins_active = self.get_plugins()
|
||||||
|
plugins_available = self.get_available_plugins()
|
||||||
|
|
||||||
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
|
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
|
||||||
|
|
||||||
for module in enable:
|
for module in enable:
|
||||||
@@ -1150,6 +1211,10 @@ class Event(EventMixin, LoggedModel):
|
|||||||
plugins_active.remove(module)
|
plugins_active.remove(module)
|
||||||
self.set_active_plugins(plugins_active)
|
self.set_active_plugins(plugins_active)
|
||||||
|
|
||||||
|
plugins_available = self.get_available_plugins()
|
||||||
|
if hasattr(plugins_available[module].app, 'uninstalled'):
|
||||||
|
getattr(plugins_available[module].app, 'uninstalled')(self)
|
||||||
|
|
||||||
regenerate_css.apply_async(args=(self.pk,))
|
regenerate_css.apply_async(args=(self.pk,))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1259,7 +1324,7 @@ class SubEvent(EventMixin, LoggedModel):
|
|||||||
verbose_name=_("Frontpage text")
|
verbose_name=_("Frontpage text")
|
||||||
)
|
)
|
||||||
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
||||||
related_name='subevents')
|
related_name='subevents', verbose_name=_('Seating plan'))
|
||||||
|
|
||||||
items = models.ManyToManyField('Item', through='SubEventItem')
|
items = models.ManyToManyField('Item', through='SubEventItem')
|
||||||
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
|
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
|
||||||
@@ -1369,7 +1434,7 @@ class SubEvent(EventMixin, LoggedModel):
|
|||||||
return self.event.currency
|
return self.event.currency
|
||||||
|
|
||||||
def allow_delete(self):
|
def allow_delete(self):
|
||||||
return not self.orderposition_set.exists()
|
return not self.orderposition_set.exists() and not self.transaction_set.exists()
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
clear_cache = kwargs.pop('clear_cache', False)
|
clear_cache = kwargs.pop('clear_cache', False)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user