Compare commits
527 Commits
gha-migrat
...
v3.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c621c5f3a | ||
|
|
df2998dd33 | ||
|
|
de98206abc | ||
|
|
dd95e807ae | ||
|
|
f08bc6c679 | ||
|
|
a0631ffba5 | ||
|
|
d7a12cc1ee | ||
|
|
2239128971 | ||
|
|
b3fd515652 | ||
|
|
6ee54f6cc1 | ||
|
|
403dc9cf98 | ||
|
|
eb462b1950 | ||
|
|
cb6d50e570 | ||
|
|
32a2a99b7b | ||
|
|
7859985ff0 | ||
|
|
7b7cf76028 | ||
|
|
4fda9b2205 | ||
|
|
ede93f1669 | ||
|
|
4af4dbd9cf | ||
|
|
820f6d52a5 | ||
|
|
8d04974caa | ||
|
|
8021c9e865 | ||
|
|
874ea6750b | ||
|
|
8f2c125435 | ||
|
|
2f21dc8c3c | ||
|
|
05135c779c | ||
|
|
f08f06ddff | ||
|
|
9c28537d1c | ||
|
|
95b1a4a434 | ||
|
|
019801e9dc | ||
|
|
3e97fd87d1 | ||
|
|
8bcc0e4641 | ||
|
|
878becfee9 | ||
|
|
273c34999c | ||
|
|
3f4f9d98de | ||
|
|
d703eeb770 | ||
|
|
b7754d8737 | ||
|
|
ed62ecaccb | ||
|
|
5c3ef3f2b9 | ||
|
|
f3282807e2 | ||
|
|
52944ff3a3 | ||
|
|
23a9018988 | ||
|
|
936c771d5e | ||
|
|
d064a7affa | ||
|
|
4b894eb433 | ||
|
|
6c7ef89779 | ||
|
|
e8f3a66a8e | ||
|
|
d999971249 | ||
|
|
fb701f25f4 | ||
|
|
913596459a | ||
|
|
04e9ea1ae7 | ||
|
|
5dc09019ff | ||
|
|
7c3671b383 | ||
|
|
b91c16538e | ||
|
|
9ba517109d | ||
|
|
5f86fbc21d | ||
|
|
fae35cc56f | ||
|
|
78c5eb4516 | ||
|
|
860f4c36a4 | ||
|
|
311dcfaab0 | ||
|
|
83a6041a32 | ||
|
|
a2f9bb73ad | ||
|
|
fb92c9dd64 | ||
|
|
aa1910fd70 | ||
|
|
f66c266ff7 | ||
|
|
7cc5179e85 | ||
|
|
f633cc3103 | ||
|
|
5e212c83e4 | ||
|
|
2d5768aa20 | ||
|
|
8ea66bc05b | ||
|
|
eba17e22fb | ||
|
|
620c956ef8 | ||
|
|
35debba865 | ||
|
|
a635ea527e | ||
|
|
6e76db40ed | ||
|
|
7956074d8b | ||
|
|
7a3418e32f | ||
|
|
0bfc436970 | ||
|
|
b6fc02255d | ||
|
|
a06f94fde1 | ||
|
|
d5073f416c | ||
|
|
b32ea0dec4 | ||
|
|
932851cf96 | ||
|
|
4513cd7ec3 | ||
|
|
261878b3fe | ||
|
|
31590f7e6c | ||
|
|
ebe7560f14 | ||
|
|
c5b722ebc1 | ||
|
|
5ea961819d | ||
|
|
983d734c6a | ||
|
|
c1bca2f207 | ||
|
|
118f0f55e9 | ||
|
|
d1146add38 | ||
|
|
fc18788cb8 | ||
|
|
a2eb4444b4 | ||
|
|
606d13e303 | ||
|
|
d90fcee5e1 | ||
|
|
e9a4c3845a | ||
|
|
018fac2361 | ||
|
|
41dd71879e | ||
|
|
738e5d07aa | ||
|
|
a22451140b | ||
|
|
6759506838 | ||
|
|
82bb3f3b6e | ||
|
|
cdb8a92a47 | ||
|
|
7597344897 | ||
|
|
c94d384e86 | ||
|
|
b2357b7e29 | ||
|
|
c7d1e5d069 | ||
|
|
754d498938 | ||
|
|
ec7fc05108 | ||
|
|
bbba0df6c4 | ||
|
|
65e87455ec | ||
|
|
e6d09baacc | ||
|
|
fbd38fef58 | ||
|
|
253f944951 | ||
|
|
7f30f753d7 | ||
|
|
8789a42dc1 | ||
|
|
e7740b1735 | ||
|
|
586da71a64 | ||
|
|
68697f0c6a | ||
|
|
a2e1bc9c20 | ||
|
|
89a82d75a9 | ||
|
|
a2414081af | ||
|
|
c812d39b39 | ||
|
|
c6f3fdd8e4 | ||
|
|
30e0f5ebc7 | ||
|
|
f767f2f644 | ||
|
|
750c3c5201 | ||
|
|
7d9220ae3e | ||
|
|
69879bdae0 | ||
|
|
0e245b41ee | ||
|
|
2839ee1ffd | ||
|
|
d72a03c434 | ||
|
|
b6f47f6f4a | ||
|
|
ca2dd0d6b6 | ||
|
|
c4415beb8c | ||
|
|
35c8684cd4 | ||
|
|
9bb5c57792 | ||
|
|
1c8699662d | ||
|
|
9b367cb28b | ||
|
|
896ba5b06b | ||
|
|
8f3dbba859 | ||
|
|
bf5b92c465 | ||
|
|
aef09003d9 | ||
|
|
9d22e833a6 | ||
|
|
1e121c0f75 | ||
|
|
373755a502 | ||
|
|
6f694b60ca | ||
|
|
77f76195c8 | ||
|
|
355dd4463b | ||
|
|
c0c39223aa | ||
|
|
db7f8d9658 | ||
|
|
c15344ced2 | ||
|
|
0f3f15a736 | ||
|
|
478f6e3029 | ||
|
|
4c77e2f16e | ||
|
|
80b6a3d27d | ||
|
|
89e8d3d12f | ||
|
|
baf8a4ae18 | ||
|
|
2cdaf07c46 | ||
|
|
cf76a2e24d | ||
|
|
559b4a8e66 | ||
|
|
59bf11b98d | ||
|
|
5b3551fb60 | ||
|
|
72a5008513 | ||
|
|
c5ace8447d | ||
|
|
28b82841c2 | ||
|
|
fbe10a981b | ||
|
|
21c22aa63a | ||
|
|
fb4116012f | ||
|
|
53fe4a32cd | ||
|
|
ff066898d4 | ||
|
|
cbb848b3fa | ||
|
|
98dfdd8b01 | ||
|
|
0e95a7863f | ||
|
|
0913f5bc18 | ||
|
|
d1eb4c4cce | ||
|
|
4a0a3aff59 | ||
|
|
83908fde45 | ||
|
|
143ac10991 | ||
|
|
413cbec4b9 | ||
|
|
b168516d78 | ||
|
|
d0ccc42aff | ||
|
|
7aa793f4f7 | ||
|
|
1b48b519e3 | ||
|
|
5f502776b1 | ||
|
|
985e1ac9bf | ||
|
|
df1014d62f | ||
|
|
062afc42d3 | ||
|
|
1fb861a117 | ||
|
|
0a2346778d | ||
|
|
605a21a0cf | ||
|
|
1cfec9cc99 | ||
|
|
0a97b0ce67 | ||
|
|
60eee25cd1 | ||
|
|
779ec6c3f6 | ||
|
|
988eb85c05 | ||
|
|
556cb7c46d | ||
|
|
86e3c30633 | ||
|
|
6276f213b9 | ||
|
|
524f6c9975 | ||
|
|
125a14c8e9 | ||
|
|
c7f0e6f652 | ||
|
|
1e58ef6f9e | ||
|
|
41127ce978 | ||
|
|
99b42d201e | ||
|
|
265da6c746 | ||
|
|
d58c8559fc | ||
|
|
b5dca762f0 | ||
|
|
a310c33497 | ||
|
|
fc5c3caf66 | ||
|
|
bff1041878 | ||
|
|
2626259492 | ||
|
|
18415c62bb | ||
|
|
85f546a3a6 | ||
|
|
829b0041fc | ||
|
|
4968a6d995 | ||
|
|
033deb7cf2 | ||
|
|
e23e88f5c3 | ||
|
|
c3745e792b | ||
|
|
735d4564f8 | ||
|
|
b305ac012c | ||
|
|
7bd9a01f5e | ||
|
|
8bebea61f1 | ||
|
|
6714ab24ee | ||
|
|
a54dbc0110 | ||
|
|
19fa2fb016 | ||
|
|
12b5d6663e | ||
|
|
ca4db5f628 | ||
|
|
b6a343a623 | ||
|
|
dc451cdeea | ||
|
|
6732d13439 | ||
|
|
5bf67ba613 | ||
|
|
8885b50972 | ||
|
|
940566ab93 | ||
|
|
e7b9c49620 | ||
|
|
c8ef825de5 | ||
|
|
5b25a68599 | ||
|
|
e26a07d44d | ||
|
|
6842127802 | ||
|
|
3c5948d2e0 | ||
|
|
ed3542e219 | ||
|
|
e439b20618 | ||
|
|
5c1fe6f68c | ||
|
|
e4e91523a0 | ||
|
|
00827700de | ||
|
|
0a87225a9a | ||
|
|
9371d221bf | ||
|
|
08a3c846b6 | ||
|
|
1c84de9ab2 | ||
|
|
980f4012bc | ||
|
|
591d70eabe | ||
|
|
2c4609604d | ||
|
|
30c2b8b03f | ||
|
|
a685af6433 | ||
|
|
f179a220bc | ||
|
|
b61893e3b1 | ||
|
|
d3282a1acb | ||
|
|
c585946e72 | ||
|
|
b6245b97ca | ||
|
|
51720c3afe | ||
|
|
4746b8e456 | ||
|
|
b2401f7641 | ||
|
|
b4cd11ef94 | ||
|
|
33682e1b38 | ||
|
|
e10e3300ba | ||
|
|
4f0eadfd6e | ||
|
|
0f9ec2ce7d | ||
|
|
93e3cf1d99 | ||
|
|
3affaa8c85 | ||
|
|
fddf134755 | ||
|
|
568398e4e7 | ||
|
|
fc08531639 | ||
|
|
bd25ce238d | ||
|
|
c88ce8a9a8 | ||
|
|
004403e2c8 | ||
|
|
0cde5288ac | ||
|
|
e585da2901 | ||
|
|
f369fca091 | ||
|
|
e0e638ac8c | ||
|
|
4abe906511 | ||
|
|
855a47776f | ||
|
|
c63c499a95 | ||
|
|
f06a23ae95 | ||
|
|
2b68a22aad | ||
|
|
86d42cc524 | ||
|
|
6e8040ac9d | ||
|
|
c742c9979b | ||
|
|
15f3880fcd | ||
|
|
6bf5d8cb5e | ||
|
|
27d772f52f | ||
|
|
6e9d921af6 | ||
|
|
1c9a1b5e02 | ||
|
|
640c05729b | ||
|
|
fc9e5166da | ||
|
|
b1eb5bb3df | ||
|
|
f690d74be7 | ||
|
|
c52fdc95a7 | ||
|
|
039ca36233 | ||
|
|
9920a47580 | ||
|
|
74acfbe2fd | ||
|
|
b0b8f32cb9 | ||
|
|
066cf510e3 | ||
|
|
aca963d960 | ||
|
|
582c7b50f7 | ||
|
|
d6b185193e | ||
|
|
fb92d500be | ||
|
|
27ed9ae4fd | ||
|
|
06fbf56c04 | ||
|
|
d843fc1545 | ||
|
|
cf2af3c94d | ||
|
|
5f50aa95eb | ||
|
|
626e332886 | ||
|
|
507e1a5b83 | ||
|
|
0d1aa2f96e | ||
|
|
df6038e39b | ||
|
|
922f12f55e | ||
|
|
fdea190d72 | ||
|
|
34fe34d50a | ||
|
|
3412c1d2a9 | ||
|
|
6f58f30d92 | ||
|
|
19b5610503 | ||
|
|
55670b92a8 | ||
|
|
e5cc15ffac | ||
|
|
249e6978ea | ||
|
|
a223d57124 | ||
|
|
2a5c24482e | ||
|
|
868292f9b3 | ||
|
|
5c24fd966a | ||
|
|
61490a9ee8 | ||
|
|
3c3333c485 | ||
|
|
4c19002be6 | ||
|
|
2c49eaeef8 | ||
|
|
481e29c3b2 | ||
|
|
0aebde62eb | ||
|
|
49e44f68ba | ||
|
|
84dc9f241d | ||
|
|
07b05f4a44 | ||
|
|
d1c1aed1f2 | ||
|
|
de9f7248cc | ||
|
|
0d45706608 | ||
|
|
016dd88e8b | ||
|
|
6362c27cba | ||
|
|
7396f29b82 | ||
|
|
ff9f6b6a36 | ||
|
|
7359b5543d | ||
|
|
74a0cafa0f | ||
|
|
16472e915d | ||
|
|
ec6844f900 | ||
|
|
e6455f8204 | ||
|
|
4c48fcd861 | ||
|
|
adfd7834fb | ||
|
|
a4b8315487 | ||
|
|
8594fecad4 | ||
|
|
61979f0c40 | ||
|
|
df5f8a340b | ||
|
|
651e797264 | ||
|
|
d40010fab6 | ||
|
|
89c3d59e6d | ||
|
|
b30569a941 | ||
|
|
fe230fe56d | ||
|
|
0b20d3f6f8 | ||
|
|
e895c13b28 | ||
|
|
5cc0bd5d36 | ||
|
|
569379e508 | ||
|
|
d975a68641 | ||
|
|
c992de341f | ||
|
|
11cc27dbd6 | ||
|
|
90e70eae25 | ||
|
|
9eacd38ec7 | ||
|
|
d1c96aa77c | ||
|
|
99e02bde36 | ||
|
|
e7da2aec53 | ||
|
|
d0c6f0f0e9 | ||
|
|
3ae148956f | ||
|
|
e0436039d2 | ||
|
|
29510b8617 | ||
|
|
2d88da3a67 | ||
|
|
fbb88602d4 | ||
|
|
7d7820a4ee | ||
|
|
8001063347 | ||
|
|
0c3a200355 | ||
|
|
ebb1cc1be7 | ||
|
|
45f120b0c3 | ||
|
|
bae0e45d00 | ||
|
|
057fd95706 | ||
|
|
597d4aa206 | ||
|
|
7f9b245eb5 | ||
|
|
42490c6dec | ||
|
|
60c0b7da12 | ||
|
|
7d41922274 | ||
|
|
fc7fbf31c5 | ||
|
|
da5433325c | ||
|
|
939a38d53b | ||
|
|
a57280004e | ||
|
|
ce896cec8f | ||
|
|
effc9723f1 | ||
|
|
cd5f6b66a1 | ||
|
|
0d35064d21 | ||
|
|
314ce5467f | ||
|
|
d97ef380a4 | ||
|
|
d3498c419d | ||
|
|
0d90ae9d53 | ||
|
|
796d6f92ef | ||
|
|
73faecaa39 | ||
|
|
e04e8e8ca6 | ||
|
|
89a9148073 | ||
|
|
205faafc57 | ||
|
|
db8ed065df | ||
|
|
c84ae364ba | ||
|
|
55a57dced2 | ||
|
|
62db01e353 | ||
|
|
e435e7140c | ||
|
|
f4d38965cc | ||
|
|
5d080a4ab2 | ||
|
|
7895729c38 | ||
|
|
3b003d0baa | ||
|
|
69a4a16793 | ||
|
|
cf3412d54d | ||
|
|
03bcfc7c5a | ||
|
|
a6c1c85591 | ||
|
|
1c8468c21b | ||
|
|
dcc54a0204 | ||
|
|
435b32a6b8 | ||
|
|
9e93560f7c | ||
|
|
e12e7a5dd3 | ||
|
|
e822ba5430 | ||
|
|
b1ee355663 | ||
|
|
ca40ddc39b | ||
|
|
1b85911a76 | ||
|
|
27b56b5aea | ||
|
|
5147508ef9 | ||
|
|
39ae22b8b2 | ||
|
|
65b612eabd | ||
|
|
48d2f98815 | ||
|
|
5dd5ff8a7c | ||
|
|
059bdc629e | ||
|
|
36532e5bbb | ||
|
|
ed318791fb | ||
|
|
4d61ec4d86 | ||
|
|
cc2f8ac3da | ||
|
|
770293e8ec | ||
|
|
0ab11a8134 | ||
|
|
7e56100c21 | ||
|
|
6fecb42e26 | ||
|
|
4c063272d4 | ||
|
|
029f113b06 | ||
|
|
7827b026fd | ||
|
|
bd4ccf4507 | ||
|
|
f95d2972d3 | ||
|
|
e642098a5f | ||
|
|
bf11dea798 | ||
|
|
9e47f37097 | ||
|
|
149c25e609 | ||
|
|
fccd7a5499 | ||
|
|
d16acf0bfd | ||
|
|
71c915f5f7 | ||
|
|
f1c29daa42 | ||
|
|
fec31823ee | ||
|
|
c056db46b6 | ||
|
|
640b9c876d | ||
|
|
25ad2ea475 | ||
|
|
d2dfbca913 | ||
|
|
7287a16711 | ||
|
|
c6e969b7fe | ||
|
|
c03c278ecd | ||
|
|
140f041cc1 | ||
|
|
de9c450648 | ||
|
|
6a4c81ff3c | ||
|
|
e117545b3f | ||
|
|
feb7f419d3 | ||
|
|
ea04c85486 | ||
|
|
094450564a | ||
|
|
66d3c4516f | ||
|
|
4b01c42f31 | ||
|
|
c0972ef39d | ||
|
|
4d1cbd8248 | ||
|
|
07eb71400a | ||
|
|
f6f72d4b7f | ||
|
|
08d89acffd | ||
|
|
28831d674f | ||
|
|
65f1c20af0 | ||
|
|
5b2b3d71ec | ||
|
|
15bdcb9973 | ||
|
|
1a1afcddc6 | ||
|
|
219c82b028 | ||
|
|
19e2158f19 | ||
|
|
23055cfe09 | ||
|
|
790f248388 | ||
|
|
ef8d9bdb93 | ||
|
|
0d9b534cee | ||
|
|
b1b303e598 | ||
|
|
b4f3a665af | ||
|
|
f71153db4a | ||
|
|
1643005a4b | ||
|
|
0ee2f674bc | ||
|
|
d142a09ad3 | ||
|
|
68c13aaa3c | ||
|
|
556c77a54b | ||
|
|
5b689e5fd2 | ||
|
|
57f5fbc131 | ||
|
|
7e80ec93d5 | ||
|
|
ee1928aeed | ||
|
|
5c62f2b852 | ||
|
|
ad8be705fd | ||
|
|
4635d9b5f7 | ||
|
|
ace32f3fc4 | ||
|
|
88a235da30 | ||
|
|
4ec24fc884 | ||
|
|
e2b9fe8e71 | ||
|
|
6f1c29581d | ||
|
|
9bf0a6dfcd | ||
|
|
3e6e324027 | ||
|
|
912e6816df | ||
|
|
d00aa2f3ad | ||
|
|
41fecf366c | ||
|
|
f7f751bda7 | ||
|
|
7b68614de3 | ||
|
|
c0fc259d17 | ||
|
|
d6cad265fc | ||
|
|
bb1882aca5 | ||
|
|
b887c2c43d | ||
|
|
c05bf779bd | ||
|
|
68ff001bb6 | ||
|
|
3c6fdd15af | ||
|
|
16957eec33 |
2
.github/workflows/docs.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install system packages
|
||||
run: sudo apt install enchant hunspell aspell-en
|
||||
run: sudo apt update && sudo apt install enchant hunspell aspell-en
|
||||
- name: Install Dependencies
|
||||
run: pip3 install --no-use-pep517 -Ur doc/requirements.txt
|
||||
- name: Spellcheck docs
|
||||
|
||||
6
.github/workflows/strings.yml
vendored
@@ -4,10 +4,12 @@ on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
paths:
|
||||
- 'doc/**'
|
||||
- 'src/pretix/locale/**'
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
paths:
|
||||
- 'doc/**'
|
||||
- 'src/pretix/locale/**'
|
||||
|
||||
jobs:
|
||||
@@ -27,7 +29,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install system packages
|
||||
run: sudo apt install gettext
|
||||
run: sudo apt update && sudo apt install gettext
|
||||
- name: Install Dependencies
|
||||
run: pip3 install --no-use-pep517 -Ur src/requirements.txt
|
||||
- name: Compile messages
|
||||
@@ -52,7 +54,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install system packages
|
||||
run: sudo apt install enchant hunspell hunspell-de-de aspell-en aspell-de
|
||||
run: sudo apt update && sudo apt install enchant hunspell hunspell-de-de aspell-en aspell-de
|
||||
- name: Install Dependencies
|
||||
run: pip3 install --no-use-pep517 -Ur src/requirements/dev.txt
|
||||
- name: Spellcheck translations
|
||||
|
||||
2
.github/workflows/style.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: pip3 install --no-use-pep517 -Ur src/requirements/dev.txt
|
||||
- name: Run isort
|
||||
run: isort -c -rc -df .
|
||||
run: isort -c .
|
||||
working-directory: ./src
|
||||
flake:
|
||||
name: flake8
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
@@ -5,10 +5,12 @@ on:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
- 'doc/**'
|
||||
- 'src/pretix/locale/**'
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
- 'doc/**'
|
||||
- 'src/pretix/locale/**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -53,7 +55,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install system dependencies
|
||||
run: sudo apt install gettext mysql-client
|
||||
run: sudo apt update && sudo apt install gettext mysql-client
|
||||
- name: Install Python dependencies
|
||||
run: pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt mysqlclient psycopg2-binary
|
||||
- name: Run checks
|
||||
|
||||
@@ -20,15 +20,17 @@ pypi:
|
||||
- cp /keys/.pypirc ~/.pypirc
|
||||
- virtualenv env
|
||||
- source env/bin/activate
|
||||
- pip install -U pip wheel setuptools
|
||||
- pip install -U pip wheel setuptools check-manifest twine
|
||||
- XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt
|
||||
- cd src
|
||||
- python setup.py sdist
|
||||
- pip install dist/pretix-*.tar.gz
|
||||
- python -m pretix migrate
|
||||
- python -m pretix check
|
||||
- python setup.py sdist upload
|
||||
- python setup.py bdist_wheel upload
|
||||
- check-manifest
|
||||
- python setup.py sdist bdist_wheel
|
||||
- twine check dist/*
|
||||
- twine upload dist/*
|
||||
tags:
|
||||
- python3
|
||||
only:
|
||||
|
||||
@@ -29,7 +29,7 @@ RUN apt-get update && \
|
||||
mkdir /etc/pretix && \
|
||||
mkdir /data && \
|
||||
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
|
||||
echo 'pretixuser ALL=(ALL) NOPASSWD: /usr/bin/supervisord' >> /etc/sudoers && \
|
||||
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
|
||||
mkdir /static
|
||||
|
||||
ENV LC_ALL=C.UTF-8 \
|
||||
|
||||
19
README.rst
@@ -19,9 +19,8 @@ Reinventing ticket presales, one ticket at a time.
|
||||
Project status & release cycle
|
||||
------------------------------
|
||||
|
||||
While there is always a lot to do and improve on, pretix by now has been in use for more than a dozen
|
||||
conferences that sold over ten thousand tickets combined without major problems. We therefore think of
|
||||
pretix as being stable and ready to use.
|
||||
While there is always a lot to do and improve on, pretix by now has been in use for thousands of events
|
||||
conferences that sold millions of tickets combined. We therefore think of pretix as being stable and ready to use.
|
||||
|
||||
If you want to use or extend pretix, we strongly recommend to follow our `blog`_. We will announce all
|
||||
releases there. You can always find the latest stable version on PyPI or in the ``release/X.Y`` branch of
|
||||
@@ -30,9 +29,13 @@ the sense that it does not break your data, but its APIs might change without p
|
||||
|
||||
To get started using pretix on your own server, look at the `installation guide`_ in our documentation.
|
||||
|
||||
This project is 100 percent free and open source software. If you are interested in commercial support,
|
||||
hosting services or supporting this project financially, please go to `pretix.eu`_ or contact us at
|
||||
support@pretix.eu.
|
||||
Support
|
||||
-------
|
||||
|
||||
This project is 100 percent free and open source software. You are welcome to ask questions in the GitHub
|
||||
repository. Private support via email or phone is only offered to customers of our pretix Hosted or pretix
|
||||
Enterprise offerings. If you are interested in commercial support, hosting services or supporting this project
|
||||
financially, please go to `pretix.eu`_ or contact us at support@pretix.eu.
|
||||
|
||||
Contributing
|
||||
------------
|
||||
@@ -52,8 +55,8 @@ License
|
||||
The code in this repository is published under the terms of the Apache License.
|
||||
See the LICENSE file for the complete license text.
|
||||
|
||||
This project is maintained by Raphael Michel <mail@raphaelmichel.de>. See the
|
||||
AUTHORS file for a list of all the awesome folks who contributed to this project.
|
||||
This project is maintained by Raphael Michel. See the AUTHORS file for a list of all
|
||||
the awesome folks who contributed to this project.
|
||||
|
||||
.. _installation guide: https://docs.pretix.eu/en/latest/admin/installation/index.html
|
||||
.. _developer documentation: https://docs.pretix.eu/en/latest/development/index.html
|
||||
|
||||
@@ -19,7 +19,7 @@ fi
|
||||
python3 -m pretix migrate --noinput
|
||||
|
||||
if [ "$1" == "all" ]; then
|
||||
exec sudo /usr/bin/supervisord -n -c /etc/supervisord.conf
|
||||
exec sudo -E /usr/bin/supervisord -n -c /etc/supervisord.conf
|
||||
fi
|
||||
|
||||
if [ "$1" == "webworker" ]; then
|
||||
|
||||
@@ -92,9 +92,11 @@ Example::
|
||||
|
||||
``trust_x_forwarded_proto``
|
||||
Specifies whether the ``X-Forwarded-Proto`` header can be trusted. Only set to ``on`` if you have a reverse
|
||||
proxy that actively removes and re-adds the header to make sure the correct client IP is the first value.
|
||||
proxy that actively removes and re-adds the header to make sure the correct value is set.
|
||||
Defaults to ``off``.
|
||||
|
||||
``csp_log``
|
||||
Log violations of the Content Security Policy (CSP). Defaults to ``on``.
|
||||
|
||||
Locale settings
|
||||
---------------
|
||||
@@ -337,6 +339,15 @@ application. If you want to use sentry, you need to set a DSN in the configurati
|
||||
You will be given this value by your sentry installation.
|
||||
|
||||
|
||||
Caching
|
||||
-------
|
||||
|
||||
You can adjust some caching settings to control how much storage pretix uses::
|
||||
|
||||
[cache]
|
||||
tickets=48 ; Number of hours tickets (PDF, passbook, …) are cached
|
||||
|
||||
|
||||
Secret length
|
||||
-------------
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@ This documentation is for everyone who wants to install pretix on a server.
|
||||
config
|
||||
maintainance
|
||||
scaling
|
||||
indexes
|
||||
|
||||
73
doc/admin/indexes.rst
Normal file
@@ -0,0 +1,73 @@
|
||||
Additional database indices
|
||||
===========================
|
||||
|
||||
If you have a large pretix database, some features such as search for orders or events might turn pretty slow.
|
||||
For PostgreSQL, we have compiled a list of additional database indexes that you can add to speed things up.
|
||||
Just like any index, they in turn make write operations insignificantly slower and cause the database to use
|
||||
more disk space.
|
||||
|
||||
The indexes aren't automatically created by pretix since Django does not allow us to do so only on PostgreSQL
|
||||
(and they won't work on other databases). Also, they're really not necessary if you're not having tens of
|
||||
thousands of records in your database.
|
||||
|
||||
However, this also means they won't automatically adapt if some of the referred fields change in future updates of pretix
|
||||
and you might need to re-check this page and change them manually.
|
||||
|
||||
Here is the currently recommended set of commands::
|
||||
|
||||
CREATE EXTENSION pg_trgm;
|
||||
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_event_slug
|
||||
ON pretixbase_event
|
||||
USING gin (upper("slug") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_event_name
|
||||
ON pretixbase_event
|
||||
USING gin (upper("name") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_order_code
|
||||
ON pretixbase_order
|
||||
USING gin (upper("code") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_voucher_code
|
||||
ON pretixbase_voucher
|
||||
USING gin (upper("code") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_invoice_nu1
|
||||
ON "pretixbase_invoice" (UPPER("invoice_no"));
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_invoice_nu2
|
||||
ON "pretixbase_invoice" (UPPER("full_invoice_no"));
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_organizer_name
|
||||
ON pretixbase_organizer
|
||||
USING gin (upper("name") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_organizer_slug
|
||||
ON pretixbase_organizer
|
||||
USING gin (upper("slug") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_order_email
|
||||
ON pretixbase_order
|
||||
USING gin (upper("email") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_order_comment
|
||||
ON pretixbase_order
|
||||
USING gin (upper("comment") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_name
|
||||
ON pretixbase_orderposition
|
||||
USING gin (upper("attendee_name_cached") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_scret
|
||||
ON pretixbase_orderposition
|
||||
USING gin (upper("secret") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_email
|
||||
ON pretixbase_orderposition
|
||||
USING gin (upper("attendee_email") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_ia_name
|
||||
ON pretixbase_invoiceaddress
|
||||
USING gin (upper("name_cached") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_ia_company
|
||||
ON pretixbase_invoiceaddress
|
||||
USING gin (upper("company") gin_trgm_ops);
|
||||
|
||||
|
||||
Also, if you use our ``pretix-shipping`` plugin::
|
||||
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_sa_name
|
||||
ON pretix_shipping_shippingaddress
|
||||
USING gin (upper("name") gin_trgm_ops);
|
||||
CREATE INDEX CONCURRENTLY pretix_addidx_sa_company
|
||||
ON pretix_shipping_shippingaddress
|
||||
USING gin (upper("company") gin_trgm_ops);
|
||||
|
||||
@@ -26,7 +26,7 @@ installation guides):
|
||||
* `Docker`_
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `PostgreSQL`_, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `PostgreSQL`_ 9.5+, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `redis`_ server
|
||||
|
||||
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
||||
@@ -290,7 +290,7 @@ to re-build your custom image after you pulled ``pretix/standalone`` if you want
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
.. _pretix.eu: https://pretix.eu/
|
||||
.. _MySQL: https://dev.mysql.com/doc/refman/5.7/en/linux-installation-apt-repo.html
|
||||
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-9-4-on-debian-8
|
||||
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04
|
||||
.. _redis: https://blog.programster.org/debian-8-install-redis-server/
|
||||
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall
|
||||
.. _redis website: https://redis.io/topics/security
|
||||
|
||||
@@ -23,7 +23,7 @@ installation guides):
|
||||
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `PostgreSQL`_, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `PostgreSQL`_ 9.5+, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `redis`_ server
|
||||
|
||||
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
||||
@@ -308,7 +308,7 @@ example::
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
.. _pretix.eu: https://pretix.eu/
|
||||
.. _MySQL: https://dev.mysql.com/doc/refman/5.7/en/linux-installation-apt-repo.html
|
||||
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-9-4-on-debian-8
|
||||
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04
|
||||
.. _redis: https://blog.programster.org/debian-8-install-redis-server/
|
||||
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall
|
||||
.. _strong encryption settings: https://mozilla.github.io/server-side-tls/ssl-config-generator/
|
||||
|
||||
@@ -92,7 +92,8 @@ pretix_task_duration_seconds
|
||||
|
||||
pretix_model_instances
|
||||
Gauge. Measures number of instances of a certain model within the database, labeled with
|
||||
the ``model`` name.
|
||||
the ``model`` name. Starting with pretix 3.11, these numbers might only be approximate for
|
||||
most tables when running on PostgreSQL to mitigate performance impact.
|
||||
|
||||
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
|
||||
.. _Prometheus: https://prometheus.io/
|
||||
|
||||
@@ -7,9 +7,6 @@ This part of the documentation contains information about the REST-style API
|
||||
exposed by pretix since version 1.5 that can be used by third-party programs
|
||||
to interact with pretix and its data structures.
|
||||
|
||||
Currently, the API provides mostly read-only capabilities, but it will be extended
|
||||
in functionality over time.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ position_count integer Number of ticke
|
||||
checkin_count integer Number of check-ins performed on this list (read-only).
|
||||
include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state.
|
||||
auto_checkin_sales_channels list of strings All items on the check-in list will be automatically marked as checked-in when purchased through any of the listed sales channels.
|
||||
allow_multiple_entries boolean If ``true``, subsequent scans of a ticket on this list should not show a warning but instead be stored as an additional check-in.
|
||||
allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan.
|
||||
rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.10
|
||||
@@ -48,6 +51,15 @@ auto_checkin_sales_channels list of strings All items on th
|
||||
|
||||
The ``auto_checkin_sales_channels`` field has been added.
|
||||
|
||||
.. versionchanged:: 3.9
|
||||
|
||||
The ``subevent`` attribute may now be ``null`` inside event series. The ``allow_multiple_entries``,
|
||||
``allow_entry_after_exit``, and ``rules`` attributes have been added.
|
||||
|
||||
.. versionchanged:: 3.11
|
||||
|
||||
The ``subevent_match`` and ``exclude`` query parameters have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -89,6 +101,9 @@ Endpoints
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"rules": {},
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -98,6 +113,8 @@ Endpoints
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query integer subevent: Only return check-in lists of the sub-event with the given ID
|
||||
:query integer subevent_match: Only return check-in lists that are valid for the sub-event with the given ID (i.e. also lists valid for all subevents)
|
||||
:query string exclude: Exclude a field from the output, e.g. ``checkin_count``. Can be used as a performance optimization. Can be passed multiple times.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
@@ -133,6 +150,9 @@ Endpoints
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"rules": {},
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -229,6 +249,8 @@ Endpoints
|
||||
"all_products": false,
|
||||
"limit_products": [1, 2],
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -251,6 +273,8 @@ Endpoints
|
||||
"limit_products": [1, 2],
|
||||
"include_pending": false,
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -303,6 +327,8 @@ Endpoints
|
||||
"limit_products": [1, 2],
|
||||
"include_pending": false,
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -582,6 +608,7 @@ Order position endpoints
|
||||
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
|
||||
:<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required
|
||||
questions that have not been filled. Defaults to ``false``.
|
||||
:<json 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.
|
||||
Defaults to ``false`` and only works when ``include_pending`` is set on the check-in
|
||||
list.
|
||||
@@ -696,6 +723,7 @@ Order position endpoints
|
||||
``canceled_supported`` to ``true``, otherwise these orders return ``unpaid``.
|
||||
* ``already_redeemed`` - Ticket already has been redeemed
|
||||
* ``product`` - Tickets with this product may not be scanned at this device
|
||||
* ``rules`` - Check-in prevented by a user-defined rule
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
|
||||
@@ -526,7 +526,7 @@ information about the properties.
|
||||
|
||||
Get current values of event settings.
|
||||
|
||||
Permission required: "Can change event settings"
|
||||
Permission required: "Can change event settings" (Exception: with device auth, *some* settings can always be *read*.)
|
||||
|
||||
**Example request**:
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ addon_category integer Internal ID of
|
||||
min_count integer The minimal number of add-ons that need to be chosen.
|
||||
max_count integer The maximal number of add-ons that can be chosen.
|
||||
position integer An integer, used for sorting
|
||||
multi_allowed boolean Adding the same item multiple times is allowed
|
||||
price_included boolean Adding this add-on to the item is free
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
@@ -65,6 +66,7 @@ Endpoints
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 0,
|
||||
"multi_allowed": false,
|
||||
"price_included": true
|
||||
},
|
||||
{
|
||||
@@ -73,6 +75,7 @@ Endpoints
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"multi_allowed": false,
|
||||
"price_included": true
|
||||
}
|
||||
]
|
||||
@@ -112,6 +115,7 @@ Endpoints
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"multi_allowed": false,
|
||||
"price_included": true
|
||||
}
|
||||
|
||||
@@ -141,6 +145,7 @@ Endpoints
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"multi_allowed": false,
|
||||
"price_included": true
|
||||
}
|
||||
|
||||
@@ -158,6 +163,7 @@ Endpoints
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"multi_allowed": false,
|
||||
"price_included": true
|
||||
}
|
||||
|
||||
@@ -206,6 +212,7 @@ Endpoints
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"multi_allowed": false,
|
||||
"price_included": true
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ addons list of objects Definition of a
|
||||
├ min_count integer The minimal number of add-ons that need to be chosen.
|
||||
├ max_count integer The maximal number of add-ons that can be chosen.
|
||||
├ position integer An integer, used for sorting
|
||||
├ multi_allowed boolean Adding the same item multiple times is allowed
|
||||
└ price_included boolean Adding this add-on to the item is free
|
||||
bundles list of objects Definition of bundles that are included in this item.
|
||||
Only writable during creation,
|
||||
@@ -159,6 +160,10 @@ meta_data object Values set for
|
||||
|
||||
The attribute ``meta_data`` has been added.
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The attribute ``multi_allowed`` has been added to ``addons``.
|
||||
|
||||
Notes
|
||||
-----
|
||||
|
||||
|
||||
@@ -155,6 +155,14 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``reactivate`` operation has been added.
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The ``search`` query parameter has been added.
|
||||
|
||||
.. versionchanged:: 3.11
|
||||
|
||||
The ``exclude`` and ``subevent_after`` query parameter has been added.
|
||||
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
@@ -195,6 +203,7 @@ pseudonymization_id string A random ID, e.
|
||||
checkins list of objects List of check-ins with this ticket
|
||||
├ list integer Internal ID of the check-in list
|
||||
├ datetime datetime Time of check-in
|
||||
├ type string Type of scan (defaults to ``entry``)
|
||||
└ auto_checked_in boolean Indicates if this check-in been performed automatically by the system
|
||||
downloads list of objects List of ticket download options
|
||||
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
|
||||
@@ -251,6 +260,10 @@ pdf_data object Data object req
|
||||
|
||||
The attributes ``company``, ``street``, ``zipcode``, ``city``, ``country``, and ``state`` have been added.
|
||||
|
||||
.. versionchanged:: 3.9
|
||||
|
||||
The ``checkin.type`` attribute has been added.
|
||||
|
||||
.. _order-payment-resource:
|
||||
|
||||
Order payment resource
|
||||
@@ -413,6 +426,7 @@ List of all orders
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
@@ -462,6 +476,7 @@ List of all orders
|
||||
``last_modified``, and ``status``. Default: ``datetime``
|
||||
:query string code: Only return orders that match the given order code
|
||||
:query string status: Only return orders in the given order status (see above)
|
||||
:query string search: Only return orders matching a given search query
|
||||
: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
|
||||
``require_approval`` will be returned.
|
||||
@@ -474,6 +489,8 @@ List of all orders
|
||||
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
|
||||
you will not notice it using this method.
|
||||
:query datetime created_since: Only return orders that have been created since the given date.
|
||||
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date.
|
||||
: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.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
|
||||
@@ -575,6 +592,7 @@ Fetching individual orders
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
@@ -916,7 +934,8 @@ Creating orders
|
||||
during order generation and is not respected automatically when the order changes later.)
|
||||
|
||||
* ``force`` (optional). If set to ``true``, quotas will be ignored.
|
||||
* ``send_mail`` (optional). If set to ``true``, the same emails will be sent as for a regular order. Defaults to
|
||||
* ``send_mail`` (optional). If set to ``true``, the same emails will be sent as for a regular order, regardless of
|
||||
whether these emails are enabled for certain sales channels. Defaults to
|
||||
``false``.
|
||||
|
||||
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
|
||||
@@ -1471,6 +1490,7 @@ List of all order positions
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
@@ -1576,6 +1596,7 @@ Fetching individual positions
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ close_when_sold_out boolean If ``true``, th
|
||||
again.
|
||||
closed boolean Whether the quota is currently closed (see above
|
||||
field).
|
||||
release_after_exit boolean Whether the quota regains capacity as soon as some tickets
|
||||
have been scanned at an exit.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.10
|
||||
@@ -36,6 +38,10 @@ closed boolean Whether the quo
|
||||
|
||||
The attributes ``close_when_sold_out`` and ``closed`` have been added.
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The attribute ``release_after_exit`` has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -283,6 +289,7 @@ Endpoints
|
||||
"total_size": 1000,
|
||||
"pending_orders": 25,
|
||||
"paid_orders": 423,
|
||||
"exited_orders": 0,
|
||||
"cart_positions": 7,
|
||||
"blocking_vouchers": 126,
|
||||
"waiting_list": 0
|
||||
|
||||
@@ -39,10 +39,12 @@ geo_lon float Longitude of th
|
||||
item_price_overrides list of objects List of items for which this sub-event overrides the
|
||||
default price
|
||||
├ item integer The internal item ID
|
||||
├ disabled boolean If ``true``, item should not be available for this sub-event
|
||||
└ price money (string) The price or ``null`` for the default price
|
||||
variation_price_overrides list of objects List of variations for which this sub-event overrides
|
||||
the default price
|
||||
├ variation integer The internal variation ID
|
||||
├ disabled boolean If ``true``, variation should not be available for this sub-event
|
||||
└ price money (string) The price or ``null`` for the default price
|
||||
meta_data object Values set for organizer-specific meta data parameters.
|
||||
seating_plan integer If reserved seating is in use, the ID of a seating
|
||||
@@ -74,6 +76,10 @@ seat_category_mapping object An object mappi
|
||||
|
||||
The attributes ``geo_lat`` and ``geo_lon`` have been added.
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The ``disabled`` attribute has been added to ``item_price_overrides`` and ``variation_price_overrides``.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -125,6 +131,7 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
@@ -182,6 +189,7 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
@@ -216,6 +224,7 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
@@ -271,6 +280,7 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
@@ -307,6 +317,7 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "23.42"
|
||||
}
|
||||
],
|
||||
@@ -339,6 +350,7 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "23.42"
|
||||
}
|
||||
],
|
||||
@@ -427,6 +439,7 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -70,6 +70,9 @@ and ``checkin_list``.
|
||||
only include the minimum amount of data necessary for you to fetch the changed objects from our
|
||||
:ref:`rest-api` in an authenticated way.
|
||||
|
||||
.. warning:: In very rare cases, you could receive the same webhook notification twice. We try to avoid it, but we
|
||||
prefer it over missing a notification.
|
||||
|
||||
If you want to further prevent others from accessing your webhook URL, you can also use `Basic authentication`_ and
|
||||
supply the URL to us in the format of ``https://username:password@domain.com/path/``.
|
||||
We recommend that you use HTTPS for your webhook URL and might require it in the future. If HTTPS is used, we require
|
||||
|
||||
@@ -29,6 +29,22 @@ that we'll provide in this plugin::
|
||||
from .exporter import MyExporter
|
||||
return MyExporter
|
||||
|
||||
Some exporters might also prove to be useful, when provided on an organizer-level. In order to declare your
|
||||
exporter as capable of providing exports spanning multiple events, your plugin should listen for this signal
|
||||
and return the subclass of ``pretix.base.exporter.BaseExporter`` that we'll provide in this plugin::
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.signals import register_multievent_data_exporters
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multieventexporter_myexporter")
|
||||
def register_multievent_data_exporter(sender, **kwargs):
|
||||
from .exporter import MyExporter
|
||||
return MyExporter
|
||||
|
||||
If your exporter supports both event-level and multi-event level exports, you will need to listen for both
|
||||
signals.
|
||||
|
||||
The exporter class
|
||||
------------------
|
||||
|
||||
@@ -33,7 +33,7 @@ Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description
|
||||
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
@@ -66,19 +66,13 @@ Vouchers
|
||||
""""""""
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: item_forms
|
||||
|
||||
Vouchers
|
||||
""""""""
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: voucher_form_class, voucher_form_html, voucher_form_validation
|
||||
:members: item_forms, voucher_form_class, voucher_form_html, voucher_form_validation
|
||||
|
||||
Dashboards
|
||||
""""""""""
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: event_dashboard_widgets, user_dashboard_widgets
|
||||
:members: event_dashboard_widgets, user_dashboard_widgets, event_dashboard_top
|
||||
|
||||
Ticket designs
|
||||
""""""""""""""
|
||||
|
||||
@@ -126,6 +126,8 @@ The provider class
|
||||
|
||||
.. autoattribute:: test_mode_message
|
||||
|
||||
.. autoattribute:: requires_invoice_immediately
|
||||
|
||||
|
||||
Additional views
|
||||
----------------
|
||||
|
||||
@@ -7,7 +7,7 @@ Coding style and quality
|
||||
for more information. Use four spaces for indentation.
|
||||
|
||||
* We sort our imports by a certain schema, but you don't have to do this by hand. Again, ``setup.cfg`` contains
|
||||
some definitions that allow the command ``isort -rc <directory>`` to automatically sort the imports in your source
|
||||
some definitions that allow the command ``isort <directory>`` to automatically sort the imports in your source
|
||||
files.
|
||||
|
||||
* For templates and models, please take a look at the `Django Coding Style`_. We like Django's `class-based views`_ and
|
||||
|
||||
@@ -98,7 +98,7 @@ pull request nevertheless and ask us for help, we are happy to assist you.
|
||||
Execute the following commands to check for code style errors::
|
||||
|
||||
flake8 .
|
||||
isort -c -rc .
|
||||
isort -c .
|
||||
python manage.py check
|
||||
|
||||
Execute the following command to run pretix' test suite (might take a couple of minutes)::
|
||||
@@ -121,7 +121,7 @@ for example, to check for any errors in any staged files when committing::
|
||||
do
|
||||
echo $file
|
||||
git show ":$file" | flake8 - --stdin-display-name="$file" || exit 1 # we only want to lint the staged changes, not any un-staged changes
|
||||
git show ":$file" | isort -df --check-only - | grep ERROR && exit 1 || true
|
||||
git show ":$file" | isort -c - | grep ERROR && exit 1 || true
|
||||
done
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,86 @@
|
||||
Digital content
|
||||
===============
|
||||
|
||||
URL interpolation and JWT authentication
|
||||
----------------------------------------
|
||||
|
||||
In the simplest case, you can use the digital content module to point users to a specific piece of content on some
|
||||
platform after their ticket purchase, or show them an embedded video or live stream. However, the full power of the
|
||||
module can be utilized by passing additional information to the target system to automatically authenticate the user
|
||||
or pre-fill some fields with their data. For example, you could use an URL like this::
|
||||
|
||||
https://webinars.example.com/join?as={attendee_name}&userid={order_code}-{positionid}
|
||||
|
||||
While this is already useful, it does not provide much security – anyone could guess a valid combination for that URL.
|
||||
Therefore, the module allows you to pass information as a `JSON Web Token`_, which isn't encrypted, but signed with a
|
||||
shared secret such that nobody can create their own tokens or modify the contents. To use a token, set up a URL like this::
|
||||
|
||||
https://webinars.example.com/join?with_token={token}
|
||||
|
||||
Additionally, you will need to set a JWT secret and a token template, either through the pretix interface or through the
|
||||
API (see below). pretix currently only supports tokens signed with ``HMAC-SHA256`` (``HS256``). Your token template can contain
|
||||
whatever JSON you'd like to pass on based on the same variables, for example::
|
||||
|
||||
{
|
||||
"iss": "pretix.eu",
|
||||
"aud": "webinars.example.com",
|
||||
"user": {
|
||||
"id": "{order_code}-{positionid}",
|
||||
"product": "{product_id}",
|
||||
"variation": "{variation_id}",
|
||||
"name": "{attendee_name}"
|
||||
}
|
||||
}
|
||||
|
||||
Variables can only be used in strings inside the JSON structure.
|
||||
pretix will automatically add an ``iat`` claim with the current timestamp and an ``exp`` claim with an expiration timestamp
|
||||
based on your configuration.
|
||||
|
||||
|
||||
List of variables
|
||||
"""""""""""""""""
|
||||
|
||||
The following variables are currently supported:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
=================================== ====================================================================
|
||||
Variable Description
|
||||
=================================== ====================================================================
|
||||
``order_code`` Order code (alphanumerical, unique per order, not per ticket)
|
||||
``positionid`` ID of the ticket within the order (integer, starting at 1)
|
||||
``order_email`` E-mail address of the ticket purchaser
|
||||
``product_id`` Internal ID of the purchased product
|
||||
``product_variation`` Internal ID of the purchased product variation (or empty)
|
||||
``attendee_name`` Full name of the ticket holder (or empty)
|
||||
``attendee_name_*`` Name parts of the ticket holder, depending on configuration, e.g. ``attendee_name_given_name`` or ``attendee_name_family_name``
|
||||
``attendee_email`` E-mail address of the ticket holder (or empty)
|
||||
``attendee_company`` Company of the ticket holder (or empty)
|
||||
``attendee_street`` Street of the ticket holder's address (or empty)
|
||||
``attendee_zipcode`` ZIP code of the ticket holder's address (or empty)
|
||||
``attendee_city`` City of the ticket holder's address (or empty)
|
||||
``attendee_country`` Country code of the ticket holder's address (or empty)
|
||||
``attendee_state`` State of the ticket holder's address (or empty)
|
||||
``answer[XYZ]`` Answer to the custom question with identifier ``XYZ``
|
||||
``invoice_name`` Full name of the invoice address (or empty)
|
||||
``invoice_name_*`` Name parts of the invoice address, depending on configuration, e.g. ``invoice_name_given_name`` or ``invoice_name_family_name``
|
||||
``invoice_company`` Company of the invoice address (or empty)
|
||||
``invoice_street`` Street of the invoice address (or empty)
|
||||
``invoice_zipcode`` ZIP code of the invoice address (or empty)
|
||||
``invoice_city`` City of the invoice address (or empty)
|
||||
``invoice_country`` Country code of the invoice address (or empty)
|
||||
``invoice_state`` State of the invoice address (or empty)
|
||||
``meta_XYZ`` Value of the event's ``XYZ`` meta property
|
||||
``token`` Signed JWT (only to be used in URLs, not in tokens)
|
||||
=================================== ====================================================================
|
||||
|
||||
|
||||
API Resource description
|
||||
-------------------------
|
||||
|
||||
The digital content plugin provides a HTTP API that allows you to create new digital content for your ticket holders,
|
||||
such as live streams, videos, or material downloads.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The digital content resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
@@ -28,10 +102,13 @@ all_products boolean If ``true``, th
|
||||
limit_products list of integers List of product/item IDs. This content is only shown to buyers of these ticket types.
|
||||
position integer An integer, used for sorting
|
||||
subevent integer Date in an event series this content should be shown for. Should be ``null`` if this is not an event series or if this should be shown to all customers.
|
||||
jwt_template string Template for JWT token generation
|
||||
jwt_secret string Secret for JWT token generation
|
||||
jwt_validity integer JWT validity in days
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
API Endpoints
|
||||
-------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/digitalcontents/
|
||||
|
||||
@@ -275,3 +352,5 @@ Endpoints
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/content does not exist **or** you have no permission to change it
|
||||
|
||||
.. _JSON Web Token: https://en.wikipedia.org/wiki/JSON_Web_Token
|
||||
|
||||
@@ -16,3 +16,4 @@ If you want to **create** a plugin, please go to the
|
||||
badges
|
||||
campaigns
|
||||
digital
|
||||
webinar
|
||||
|
||||
43
doc/plugins/webinar.rst
Normal file
@@ -0,0 +1,43 @@
|
||||
pretix Webinar
|
||||
==============
|
||||
|
||||
Fetch host URLs
|
||||
---------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/webinars/
|
||||
|
||||
Returns a list of all currently available webinar calls configured for an event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/webinars/ 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
|
||||
|
||||
[
|
||||
{
|
||||
"name": "Webinar B – Sept. 8th, 2020",
|
||||
"hosturl": "http://pretix.eu/demo/museum/webinar/host/a9aded3d7bd4df60/30611a34f9fee5d3/"
|
||||
},
|
||||
{
|
||||
"name": "Webinar A – Sept. 8, 2020",
|
||||
"hosturl": "http://pretix.eu/demo/museum/webinar/host/e714x7d4a4a36a04/b9cc444665xxx757/"
|
||||
}
|
||||
]
|
||||
|
||||
:query subevent: Limit the result to the webinar(s) for a specific subevent.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to view it.
|
||||
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 70 KiB |
BIN
doc/screens/event/tax_add.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 53 KiB |
BIN
doc/screens/event/timeslots_checkinlists.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
doc/screens/event/timeslots_create.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
doc/screens/event/timeslots_create_2.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
doc/screens/event/timeslots_create_3.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
doc/screens/event/timeslots_presale.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
doc/screens/event/timeslots_settings_1.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 55 KiB |
@@ -47,6 +47,7 @@ gunicorn
|
||||
guid
|
||||
hardcoded
|
||||
hostname
|
||||
ics
|
||||
idempotency
|
||||
iframe
|
||||
incrementing
|
||||
@@ -54,6 +55,8 @@ inofficial
|
||||
invalidations
|
||||
iterable
|
||||
Jimdo
|
||||
jwt
|
||||
JWT
|
||||
libpretixprint
|
||||
libsass
|
||||
linters
|
||||
|
||||
@@ -26,6 +26,9 @@ Sender address
|
||||
we strongly recommend to use the SMTP settings below as well, otherwise your e-mails might be detected as spam
|
||||
due to the `Sender Policy Framework`_ and similar mechanisms.
|
||||
|
||||
Sender name
|
||||
This is the name associated with the sender address. By default, this is your event name.
|
||||
|
||||
Signature
|
||||
This text will be appended to all e-mails in form of a signature. This might be useful e.g. to add your contact
|
||||
details or any legal information that needs to be included with the e-mails.
|
||||
@@ -33,6 +36,15 @@ Signature
|
||||
Bcc address
|
||||
This email address will receive a copy of every event-related email.
|
||||
|
||||
Attach calendar files
|
||||
With this option, every order confirmation mail will include an ics file with name, date and location of
|
||||
your event. It can be imported into many digital calendars.
|
||||
|
||||
Sales Channels for Checkout Emails
|
||||
When you are using multiple sales channel, you may want to decide that mails for order and payment confirmation
|
||||
are only to be sent for some sales channels. For orders created through the default online shop, these emails
|
||||
must always be send. A similar option is available for ticket download reminders.
|
||||
|
||||
E-mail design
|
||||
-------------
|
||||
|
||||
|
||||
94
doc/user/events/guides/timeslots.rst
Normal file
@@ -0,0 +1,94 @@
|
||||
.. _timeslots:
|
||||
|
||||
Use case: Time slots
|
||||
====================
|
||||
|
||||
A more advanced use case of pretix is using pretix for time-slot-based access to an area with a limited visitor
|
||||
capacity, such as a museum or other attraction. This guide will show you the quickest way to set up such an event
|
||||
with pretix.
|
||||
|
||||
First of all, when creating your event, you need to select that your event represents an "event series":
|
||||
|
||||
|
||||
.. thumbnail:: ../../../screens/event/create_step1.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
You can click :ref:`here <subevents>` for a more general description of event series with pretix, but everything you
|
||||
need to know is in this chapter as well.
|
||||
|
||||
General event setup
|
||||
-------------------
|
||||
|
||||
Before you go further, set up your products that you want to sell for each time slot, such as different types of entry.
|
||||
|
||||
Creating slots
|
||||
--------------
|
||||
|
||||
To create the time slots, you need to create a number of "dates" in the event series. Select "Dates" in the navigation
|
||||
menu on the left side and click "Create many new dates". Then, first enter the pattern of your opening days. In the
|
||||
example, the museum is open week Tuesday to Sunday. We recommend to create the slots for a few weeks at a time, but not
|
||||
e.g. for a full year, since it will be more complicated to change things later.
|
||||
|
||||
.. thumbnail:: ../../../screens/event/timeslots_create.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Then, scroll to the times section and create your time slots. You can do any interval you like. If you have different
|
||||
opening times on different week days, you will need to go through the creation process multiple times.
|
||||
|
||||
.. thumbnail:: ../../../screens/event/timeslots_create_2.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Scroll further down and create one or multiple quotas that define how many people can book a ticket for that time slot.
|
||||
In this example, 50 people in total are allowed to enter within every slot:
|
||||
|
||||
.. thumbnail:: ../../../screens/event/timeslots_create_3.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Do **not** create a check-in list at this point. We will deal with this further below in the guide.
|
||||
Now, press "Save" to create your slots.
|
||||
|
||||
.. warning:: If you create a lot of time slots at once, the server might need a few minutes to create them all in our
|
||||
system. If you receive an error page because it took too long, please do not try again immediately but wait
|
||||
for a few minutes. Most likely, the slots will be created successfully even though you saw an error.
|
||||
|
||||
Event settings
|
||||
--------------
|
||||
|
||||
We recommend that you navigate to "Settings" > "General" > "Display" and set the settings "Default overview style"
|
||||
to "Week calendar":
|
||||
|
||||
.. thumbnail:: ../../../screens/event/timeslots_settings_1.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Now, your ticket shop should give users a nice weekly overview over all time slots and their availability:
|
||||
|
||||
.. thumbnail:: ../../../screens/event/timeslots_presale.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Check-in
|
||||
--------
|
||||
|
||||
If you want to scan people at the entrance of your event and only admit them at their designated time, we recommend
|
||||
the following setup: Go to "Check-in" in the main navigation on the left and create a new check-in list. Give it a name
|
||||
and do *not* choose a specific data. We will use one check-in list for all dates. Then, go to the "Advanced" tab at
|
||||
the top and set up two restrictions to make sure people can only get in during the time slot they registered for.
|
||||
You can create the rules exactly like shown in the following screenshot:
|
||||
|
||||
.. thumbnail:: ../../../screens/event/timeslots_checkinlists.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
If you want, you can enter a tolerance of e.g. "10" if you want to be a little bit more relaxed and admit people up to
|
||||
10 minutes before or after their time slot.
|
||||
|
||||
Now, download our `Android or Desktop app`_ and register it to your account. The app will ask you to select one the
|
||||
time slots, but it does not matter, you can select any one of them and then select your newly created check-in list.
|
||||
That's it, you're good to go!
|
||||
|
||||
.. _Android or Desktop app: https://pretix.eu/about/en/scan
|
||||
@@ -292,6 +292,8 @@ Flexible group sizes
|
||||
|
||||
If you want to give out discounted tickets to groups starting at a given size, but still billed per person, you can do so by creating a special **Group ticket** at the per-person price and set the **Minimum amount per order** option of the ticket to the minimal group size.
|
||||
|
||||
For more complex use cases, you can also use add-on products that can be chosen multiple times.
|
||||
|
||||
This way, your ticket can be bought an arbitrary number of times – but no less than the given minimal amount per order.
|
||||
|
||||
Fixed group sizes
|
||||
@@ -344,3 +346,13 @@ In addition to your normal conference quota, you need to create an unlimited quo
|
||||
Then, head to the **Bundled products** tab of the "conference ticket" and add the "conference food" as a bundled product with a **designated price** of € 150.
|
||||
|
||||
Once a customer tries to buy the € 450 conference ticket, a sub-product will be added and the price will automatically be split into the two components, leading to a correct computation of taxes.
|
||||
|
||||
You can find more use cases in these specialized guides:
|
||||
|
||||
More use cases
|
||||
--------------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
guides/timeslots
|
||||
|
||||
@@ -136,10 +136,15 @@ If you want to include all your public events, you can just reference your organ
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/"></pretix-widget>
|
||||
|
||||
There is an optional ``style`` parameter that let's you choose between a calendar view and a list view. If you do not set it, the choice will be taken from your organizer settings::
|
||||
There is an optional ``style`` parameter that let's you choose between a monthly calendar view, a week view and a list
|
||||
view. If you do not set it, the choice will be taken from your organizer settings::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/series/" style="list"></pretix-widget>
|
||||
<pretix-widget event="https://pretix.eu/demo/series/" style="calendar"></pretix-widget>
|
||||
<pretix-widget event="https://pretix.eu/demo/series/" style="week"></pretix-widget>
|
||||
|
||||
If you have more than 100 events, the system might refuse to show a list view and always show a calendar for performance
|
||||
reasons instead.
|
||||
|
||||
You can see an example here:
|
||||
|
||||
|
||||
@@ -58,28 +58,6 @@ method without creating a new order. If payment deadlines were dependent on the
|
||||
forth could either allow someone to extend their deadline forever, or render someones order invalid by moving the date
|
||||
back in the past.
|
||||
|
||||
How can I revert a check-in?
|
||||
----------------------------
|
||||
|
||||
Neither our apps nor our web interface can currently undo the check-in of a tickets. We know that this is
|
||||
inconvenient for some of you, but we have a good reason for it:
|
||||
|
||||
Our Desktop and Android apps both support an asynchronous mode in which they can scan tickets while staying
|
||||
independent of their internet connection. When scanning with multiple devices, it can of course happen that two
|
||||
devices scan the same ticket without knowing of the other scan. As soon as one of the devices regains connectivity, it
|
||||
will upload its activity and the server marks the ticket as checked in -- regardless of the order in which the two
|
||||
scans were made and uploaded (which could be two different orders).
|
||||
|
||||
If we'd provide a "check out" feature, it would not only be used to fix an accidental scan, but scan at entry and
|
||||
exit to count the current number of people inside etc. In this case, the order of operations matters very much for them
|
||||
to make sense and provide useful results. This makes implementing an asynchronous mode much more complicated.
|
||||
|
||||
In this trade off, we chose offline-capabilities over the check out feature. We plan on solving this problem in the
|
||||
future, but we're not there yet.
|
||||
|
||||
If you're just *testing* the check-in capabilities and want to clean out everything for the real process, you can just
|
||||
delete and re-create the check-in list.
|
||||
|
||||
Why does pretix not support any 1D (linear) bar codes?
|
||||
------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
General settings
|
||||
================
|
||||
|
||||
At "Settings" → "Payment", you can configure every aspect related to the payments you want to accept. The upper part
|
||||
of the page shows a number of general settings that affect all payment methods:
|
||||
At "Settings" → "Payment", you can configure every aspect related to the payments you want to accept. The "Deadline"
|
||||
and "Advanced" tabs of the page show a number of general settings that affect all payment methods:
|
||||
|
||||
.. thumbnail:: ../../screens/event/settings_payment.png
|
||||
:align: center
|
||||
|
||||
@@ -3,6 +3,7 @@ include README.rst
|
||||
recursive-include pretix/static *
|
||||
recursive-include pretix/static.dist *
|
||||
recursive-include pretix/locale *
|
||||
recursive-include pretix/helpers/locale *
|
||||
recursive-include pretix/base/templates *
|
||||
recursive-include pretix/control/templates *
|
||||
recursive-include pretix/presale/templates *
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.9.0.dev0"
|
||||
__version__ = "3.11.1"
|
||||
|
||||
@@ -14,7 +14,19 @@ class CheckinListSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = CheckinList
|
||||
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
|
||||
'include_pending', 'auto_checkin_sales_channels')
|
||||
'include_pending', 'auto_checkin_sales_channels', 'allow_multiple_entries', 'allow_entry_after_exit',
|
||||
'rules')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
for exclude_field in self.context['request'].query_params.getlist('exclude'):
|
||||
p = exclude_field.split('.')
|
||||
if p[0] in self.fields:
|
||||
if len(p) == 1:
|
||||
del self.fields[p[0]]
|
||||
elif len(p) == 2:
|
||||
self.fields[p[0]].child.fields.pop(p[1])
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -28,9 +40,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(_('One or more items do not belong to this event.'))
|
||||
|
||||
if event.has_subevents:
|
||||
if not full_data.get('subevent'):
|
||||
raise ValidationError(_('Subevent cannot be null for event series.'))
|
||||
if event != full_data.get('subevent').event:
|
||||
if full_data.get('subevent') and event != full_data.get('subevent').event:
|
||||
raise ValidationError(_('The subevent does not belong to this event.'))
|
||||
else:
|
||||
if full_data.get('subevent'):
|
||||
|
||||
@@ -29,6 +29,9 @@ class MetaDataField(Field):
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if not isinstance(data, dict) or not all(isinstance(k, str) for k in data.keys()):
|
||||
raise ValidationError('meta_data needs to be an object (str -> str).')
|
||||
|
||||
return {
|
||||
'meta_data': data
|
||||
}
|
||||
@@ -42,6 +45,8 @@ class MetaPropertyField(Field):
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if not isinstance(data, dict) or not all(isinstance(k, str) for k in data.keys()) or not all(isinstance(k, str) for k in data.values()):
|
||||
raise ValidationError('item_meta_properties needs to be an object (str -> str).')
|
||||
return {
|
||||
'item_meta_properties': data
|
||||
}
|
||||
@@ -58,6 +63,8 @@ class SeatCategoryMappingField(Field):
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if not isinstance(data, dict) or not all(isinstance(k, str) for k in data.keys()) or not all(isinstance(k, int) for k in data.values()):
|
||||
raise ValidationError('seat_category_mapping needs to be an object (str -> int).')
|
||||
return {
|
||||
'seat_category_mapping': data or {}
|
||||
}
|
||||
@@ -341,13 +348,13 @@ class CloneEventSerializer(EventSerializer):
|
||||
class SubEventItemSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = SubEventItem
|
||||
fields = ('item', 'price')
|
||||
fields = ('item', 'price', 'disabled')
|
||||
|
||||
|
||||
class SubEventItemVariationSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = SubEventItemVariation
|
||||
fields = ('variation', 'price')
|
||||
fields = ('variation', 'price', 'disabled')
|
||||
|
||||
|
||||
class SubEventSerializer(I18nAwareModelSerializer):
|
||||
@@ -452,27 +459,29 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
|
||||
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
|
||||
item_price_overrides_data = validated_data.pop('subeventitem_set', None)
|
||||
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set', None)
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
|
||||
subevent = super().update(instance, validated_data)
|
||||
|
||||
existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)}
|
||||
if item_price_overrides_data is not None:
|
||||
existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)}
|
||||
|
||||
for item_price_override_data in item_price_overrides_data:
|
||||
id = existing_item_overrides.pop(item_price_override_data['item'], None)
|
||||
SubEventItem(id=id, subevent=subevent, **item_price_override_data).save()
|
||||
for item_price_override_data in item_price_overrides_data:
|
||||
id = existing_item_overrides.pop(item_price_override_data['item'], None)
|
||||
SubEventItem(id=id, subevent=subevent, **item_price_override_data).save()
|
||||
|
||||
SubEventItem.objects.filter(id__in=existing_item_overrides.values()).delete()
|
||||
SubEventItem.objects.filter(id__in=existing_item_overrides.values()).delete()
|
||||
|
||||
existing_variation_overrides = {item.variation: item.id for item in SubEventItemVariation.objects.filter(subevent=subevent)}
|
||||
if variation_price_overrides_data is not None:
|
||||
existing_variation_overrides = {item.variation: item.id for item in SubEventItemVariation.objects.filter(subevent=subevent)}
|
||||
|
||||
for variation_price_override_data in variation_price_overrides_data:
|
||||
id = existing_variation_overrides.pop(variation_price_override_data['variation'], None)
|
||||
SubEventItemVariation(id=id, subevent=subevent, **variation_price_override_data).save()
|
||||
for variation_price_override_data in variation_price_overrides_data:
|
||||
id = existing_variation_overrides.pop(variation_price_override_data['variation'], None)
|
||||
SubEventItemVariation(id=id, subevent=subevent, **variation_price_override_data).save()
|
||||
|
||||
SubEventItemVariation.objects.filter(id__in=existing_variation_overrides.values()).delete()
|
||||
SubEventItemVariation.objects.filter(id__in=existing_variation_overrides.values()).delete()
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
@@ -555,6 +564,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'meta_noindex',
|
||||
'redirect_to_checkout_directly',
|
||||
'frontpage_subevent_ordering',
|
||||
'event_list_type',
|
||||
'frontpage_text',
|
||||
'attendee_names_asked',
|
||||
'attendee_names_required',
|
||||
@@ -564,11 +574,13 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'attendee_addresses_required',
|
||||
'attendee_company_asked',
|
||||
'attendee_company_required',
|
||||
'confirm_text',
|
||||
'confirm_texts',
|
||||
'order_email_asked_twice',
|
||||
'payment_term_mode',
|
||||
'payment_term_days',
|
||||
'payment_term_last',
|
||||
'payment_term_weekdays',
|
||||
'payment_term_minutes',
|
||||
'payment_term_last',
|
||||
'payment_term_expire_automatically',
|
||||
'payment_term_accept_late',
|
||||
'payment_explanation',
|
||||
@@ -596,6 +608,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'invoice_numbers_consecutive',
|
||||
'invoice_numbers_prefix',
|
||||
'invoice_numbers_prefix_cancellations',
|
||||
'invoice_numbers_counter_length',
|
||||
'invoice_attendee_name',
|
||||
'invoice_include_expire_date',
|
||||
'invoice_address_explanation_text',
|
||||
@@ -610,6 +623,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'invoice_introductory_text',
|
||||
'invoice_additional_text',
|
||||
'invoice_footer_text',
|
||||
'invoice_eu_currencies',
|
||||
'cancel_allow_user',
|
||||
'cancel_allow_user_until',
|
||||
'cancel_allow_user_paid',
|
||||
@@ -621,6 +635,9 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'cancel_allow_user_paid_adjust_fees_explanation',
|
||||
'cancel_allow_user_paid_refund_as_giftcard',
|
||||
'cancel_allow_user_paid_require_approval',
|
||||
'change_allow_user_variation',
|
||||
'change_allow_user_until',
|
||||
'change_allow_user_price',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -628,9 +645,13 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
super().__init__(*args, **kwargs)
|
||||
for fname in self.default_fields:
|
||||
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
|
||||
if callable(kwargs):
|
||||
kwargs = kwargs()
|
||||
kwargs.setdefault('required', False)
|
||||
kwargs.setdefault('allow_null', True)
|
||||
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
|
||||
if callable(form_kwargs):
|
||||
form_kwargs = form_kwargs()
|
||||
if 'serializer_class' not in DEFAULTS[fname]:
|
||||
raise ValidationError('{} has no serializer class'.format(fname))
|
||||
f = DEFAULTS[fname]['serializer_class'](
|
||||
@@ -659,3 +680,40 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
settings_dict.update(data)
|
||||
validate_settings(self.event, settings_dict)
|
||||
return data
|
||||
|
||||
|
||||
class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
default_fields = [
|
||||
'locales',
|
||||
'locale',
|
||||
'last_order_modification_date',
|
||||
'show_quota_left',
|
||||
'max_items_per_order',
|
||||
'attendee_names_asked',
|
||||
'attendee_names_required',
|
||||
'attendee_emails_asked',
|
||||
'attendee_emails_required',
|
||||
'attendee_addresses_asked',
|
||||
'attendee_addresses_required',
|
||||
'attendee_company_asked',
|
||||
'attendee_company_required',
|
||||
'ticket_download',
|
||||
'ticket_download_addons',
|
||||
'ticket_download_nonadm',
|
||||
'ticket_download_pending',
|
||||
'invoice_address_asked',
|
||||
'invoice_address_required',
|
||||
'invoice_address_vatid',
|
||||
'invoice_address_company_required',
|
||||
'invoice_address_beneficiary',
|
||||
'invoice_address_custom_field',
|
||||
'invoice_name_required',
|
||||
'invoice_address_not_asked_free',
|
||||
'invoice_address_from_name',
|
||||
'invoice_address_from',
|
||||
'invoice_address_from_zipcode',
|
||||
'invoice_address_from_city',
|
||||
'invoice_address_from_country',
|
||||
'invoice_address_from_tax_id',
|
||||
'invoice_address_from_vat_id',
|
||||
]
|
||||
|
||||
@@ -45,7 +45,7 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemAddOn
|
||||
fields = ('addon_category', 'min_count', 'max_count',
|
||||
'position', 'price_included')
|
||||
'position', 'price_included', 'multi_allowed')
|
||||
|
||||
|
||||
class ItemBundleSerializer(serializers.ModelSerializer):
|
||||
@@ -77,7 +77,7 @@ class ItemAddOnSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemAddOn
|
||||
fields = ('id', 'addon_category', 'min_count', 'max_count',
|
||||
'position', 'price_included')
|
||||
'position', 'price_included', 'multi_allowed')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -349,7 +349,7 @@ class QuotaSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Quota
|
||||
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out')
|
||||
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out', 'release_after_exit')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
@@ -68,7 +68,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
|
||||
if data.get('country'):
|
||||
if not pycountry.countries.get(alpha_2=data.get('country')):
|
||||
if not pycountry.countries.get(alpha_2=data.get('country').code):
|
||||
raise ValidationError(
|
||||
{'country': ['Invalid country code.']}
|
||||
)
|
||||
@@ -122,7 +122,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
class CheckinSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = ('datetime', 'list', 'auto_checked_in')
|
||||
fields = ('datetime', 'list', 'auto_checked_in', 'type')
|
||||
|
||||
|
||||
class OrderDownloadsField(serializers.Field):
|
||||
@@ -270,8 +270,9 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state',
|
||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'require_attention',
|
||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention',
|
||||
'order__status')
|
||||
|
||||
|
||||
@@ -376,6 +377,14 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
if not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
|
||||
self.fields['positions'].child.fields.pop('pdf_data')
|
||||
|
||||
for exclude_field in self.context['request'].query_params.getlist('exclude'):
|
||||
p = exclude_field.split('.')
|
||||
if p[0] in self.fields:
|
||||
if len(p) == 1:
|
||||
del self.fields[p[0]]
|
||||
elif len(p) == 2:
|
||||
self.fields[p[0]].child.fields.pop(p[1])
|
||||
|
||||
def validate_locale(self, l):
|
||||
if l not in set(k for k in self.instance.event.settings.locales):
|
||||
raise ValidationError('"{}" is not a supported locale for this event.'.format(l))
|
||||
@@ -416,16 +425,26 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
return instance
|
||||
|
||||
|
||||
class SimulatedOrderPositionSerializer(OrderPositionSerializer):
|
||||
addon_to = serializers.SlugRelatedField(read_only=True, slug_field='positionid')
|
||||
|
||||
|
||||
class SimulatedOrderSerializer(OrderSerializer):
|
||||
positions = SimulatedOrderPositionSerializer(many=True, read_only=True)
|
||||
|
||||
|
||||
class PriceCalcSerializer(serializers.Serializer):
|
||||
item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
|
||||
variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
|
||||
subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True)
|
||||
tax_rule = serializers.PrimaryKeyRelatedField(queryset=TaxRule.objects.none(), required=False, allow_null=True)
|
||||
locale = serializers.CharField(allow_null=True, required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['item'].queryset = event.items.all()
|
||||
self.fields['tax_rule'].queryset = event.tax_rules.all()
|
||||
self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=event)
|
||||
if event.has_subevents:
|
||||
self.fields['subevent'].queryset = event.subevents.all()
|
||||
@@ -590,7 +609,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
|
||||
if data.get('country'):
|
||||
if not pycountry.countries.get(alpha_2=data.get('country')):
|
||||
if not pycountry.countries.get(alpha_2=data.get('country').code):
|
||||
raise ValidationError(
|
||||
{'country': ['Invalid country code.']}
|
||||
)
|
||||
@@ -903,6 +922,19 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if pos_data['voucher'].allow_ignore_quota or pos_data['voucher'].block_quota:
|
||||
continue
|
||||
|
||||
if pos_data.get('subevent'):
|
||||
if pos_data.get('item').pk in pos_data['subevent'].item_overrides and pos_data['subevent'].item_overrides[pos_data['item'].pk].disabled:
|
||||
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format(
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
if (
|
||||
pos_data.get('variation') and pos_data['variation'].pk in pos_data['subevent'].var_overrides and
|
||||
pos_data['subevent'].var_overrides[pos_data['variation'].pk].disabled
|
||||
):
|
||||
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format(
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
|
||||
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
|
||||
if pos_data.get('variation')
|
||||
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
|
||||
@@ -963,7 +995,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
else:
|
||||
pos.order = order
|
||||
if addon_to:
|
||||
pos.addon_to = pos_map[addon_to]
|
||||
if simulate:
|
||||
pos.addon_to = pos_map[addon_to]._wrapped
|
||||
else:
|
||||
pos.addon_to = pos_map[addon_to]
|
||||
|
||||
if pos.price is None:
|
||||
price = get_price(
|
||||
|
||||
@@ -6,6 +6,7 @@ from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.api.models import ApiCall, WebHookCall
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.helpers.periodic import minimum_interval
|
||||
|
||||
register_webhook_events = Signal(
|
||||
providing_args=[]
|
||||
@@ -19,11 +20,13 @@ instances.
|
||||
|
||||
@receiver(periodic_task)
|
||||
@scopes_disabled()
|
||||
@minimum_interval(minutes_after_success=12 * 60)
|
||||
def cleanup_webhook_logs(sender, **kwargs):
|
||||
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()
|
||||
|
||||
|
||||
@receiver(periodic_task)
|
||||
@scopes_disabled()
|
||||
@minimum_interval(minutes_after_success=12 * 60)
|
||||
def cleanup_api_logs(sender, **kwargs):
|
||||
ApiCall.objects.filter(created__lte=now() - timedelta(hours=24)).delete()
|
||||
|
||||
@@ -41,8 +41,8 @@ class ConditionalListView:
|
||||
return super().list(request, **kwargs)
|
||||
|
||||
lmd = request.event.logentry_set.filter(
|
||||
content_type__model=self.queryset.model._meta.model_name,
|
||||
content_type__app_label=self.queryset.model._meta.app_label,
|
||||
content_type__model=self.get_queryset().model._meta.model_name,
|
||||
content_type__app_label=self.get_queryset().model._meta.app_label,
|
||||
).aggregate(
|
||||
m=Max('datetime')
|
||||
)['m']
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import django_filters
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count, F, Max, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
@@ -27,10 +28,17 @@ from pretix.helpers.database import FixedOrderBy
|
||||
|
||||
with scopes_disabled():
|
||||
class CheckinListFilter(FilterSet):
|
||||
subevent_match = django_filters.NumberFilter(method='subevent_match_qs')
|
||||
|
||||
class Meta:
|
||||
model = CheckinList
|
||||
fields = ['subevent']
|
||||
|
||||
def subevent_match_qs(self, qs, name, value):
|
||||
return qs.filter(
|
||||
Q(subevent_id=value) | Q(subevent_id__isnull=True)
|
||||
)
|
||||
|
||||
|
||||
class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = CheckinListSerializer
|
||||
@@ -88,8 +96,9 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
pqs = OrderPosition.objects.filter(
|
||||
order__event=clist.event,
|
||||
order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []),
|
||||
subevent=clist.subevent,
|
||||
)
|
||||
if clist.subevent:
|
||||
pqs = pqs.filter(subevent=clist.subevent)
|
||||
if not clist.all_products:
|
||||
pqs = pqs.filter(item__in=clist.limit_products.values_list('id', flat=True))
|
||||
cqs = cqs.filter(position__item__in=clist.limit_products.values_list('id', flat=True))
|
||||
@@ -191,7 +200,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
except ValueError:
|
||||
raise Http404()
|
||||
|
||||
def get_queryset(self, ignore_status=False):
|
||||
def get_queryset(self, ignore_status=False, ignore_products=False):
|
||||
cqs = Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
list_id=self.checkinlist.pk
|
||||
@@ -201,10 +210,13 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.request.event,
|
||||
subevent=self.checkinlist.subevent
|
||||
).annotate(
|
||||
last_checked_in=Subquery(cqs)
|
||||
)
|
||||
if self.checkinlist.subevent:
|
||||
qs = qs.filter(
|
||||
subevent=self.checkinlist.subevent
|
||||
)
|
||||
|
||||
if self.request.query_params.get('ignore_status', 'false') != 'true' and not ignore_status:
|
||||
qs = qs.filter(
|
||||
@@ -243,23 +255,40 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
|
||||
|
||||
if not self.checkinlist.all_products:
|
||||
if not self.checkinlist.all_products and not ignore_products:
|
||||
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
|
||||
|
||||
return qs
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
@action(detail=False, methods=['POST'], url_name='redeem', url_path='(?P<pk>[^/]+)/redeem')
|
||||
def redeem(self, *args, **kwargs):
|
||||
force = bool(self.request.data.get('force', False))
|
||||
type = self.request.data.get('type', None) or Checkin.TYPE_ENTRY
|
||||
if type not in dict(Checkin.CHECKIN_TYPES):
|
||||
raise ValidationError("Invalid check-in type.")
|
||||
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
|
||||
nonce = self.request.data.get('nonce')
|
||||
op = self.get_object(ignore_status=True)
|
||||
|
||||
if 'datetime' in self.request.data:
|
||||
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
|
||||
else:
|
||||
dt = now()
|
||||
|
||||
try:
|
||||
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
|
||||
if self.kwargs['pk'].isnumeric():
|
||||
op = queryset.get(Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
||||
else:
|
||||
op = queryset.get(secret=self.kwargs['pk'])
|
||||
except OrderPosition.DoesNotExist:
|
||||
self.request.event.log_action('pretix.event.checkin.unknown', data={
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk,
|
||||
'barcode': self.kwargs['pk']
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
raise Http404()
|
||||
|
||||
given_answers = {}
|
||||
if 'answers' in self.request.data:
|
||||
aws = self.request.data.get('answers')
|
||||
@@ -283,6 +312,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
canceled_supported=self.request.data.get('canceled_supported', False),
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
type=type,
|
||||
)
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
@@ -294,6 +324,14 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
]
|
||||
}, status=400)
|
||||
except CheckInError as e:
|
||||
op.order.log_action('pretix.event.checkin.denied', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'errorcode': e.code,
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': e.code,
|
||||
@@ -306,11 +344,3 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=201)
|
||||
|
||||
def get_object(self, ignore_status=False):
|
||||
queryset = self.filter_queryset(self.get_queryset(ignore_status=ignore_status))
|
||||
if self.kwargs['pk'].isnumeric():
|
||||
obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
||||
else:
|
||||
obj = get_object_or_404(queryset, secret=self.kwargs['pk'])
|
||||
return obj
|
||||
|
||||
@@ -10,8 +10,8 @@ from rest_framework.response import Response
|
||||
|
||||
from pretix.api.auth.permission import EventCRUDPermission
|
||||
from pretix.api.serializers.event import (
|
||||
CloneEventSerializer, EventSerializer, EventSettingsSerializer,
|
||||
SubEventSerializer, TaxRuleSerializer,
|
||||
CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer,
|
||||
EventSettingsSerializer, SubEventSerializer, TaxRuleSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
@@ -337,10 +337,16 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
|
||||
|
||||
class EventSettingsView(views.APIView):
|
||||
permission = 'can_change_event_settings'
|
||||
permission = None
|
||||
write_permission = 'can_change_event_settings'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
if isinstance(request.auth, Device):
|
||||
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
elif 'can_change_event_settings' in request.eventpermset:
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
else:
|
||||
raise PermissionDenied()
|
||||
if 'explain' in request.GET:
|
||||
return Response({
|
||||
fname: {
|
||||
|
||||
@@ -20,6 +20,7 @@ from pretix.base.models import (
|
||||
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation,
|
||||
Question, QuestionOption, Quota,
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
with scopes_disabled():
|
||||
@@ -533,14 +534,18 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
def availability(self, request, *args, **kwargs):
|
||||
quota = self.get_object()
|
||||
|
||||
avail = quota.availability()
|
||||
qa = QuotaAvailability()
|
||||
qa.queue(quota)
|
||||
qa.compute()
|
||||
avail = qa.results[quota]
|
||||
|
||||
data = {
|
||||
'paid_orders': quota.count_paid_orders(),
|
||||
'pending_orders': quota.count_pending_orders(),
|
||||
'blocking_vouchers': quota.count_blocking_vouchers(),
|
||||
'cart_positions': quota.count_in_cart(),
|
||||
'waiting_list': quota.count_waiting_list_pending(),
|
||||
'paid_orders': qa.count_paid_orders[quota],
|
||||
'pending_orders': qa.count_pending_orders[quota],
|
||||
'exited_orders': qa.count_exited_orders[quota],
|
||||
'blocking_vouchers': qa.count_vouchers[quota],
|
||||
'cart_positions': qa.count_cart[quota],
|
||||
'waiting_list': qa.count_pending_orders[quota],
|
||||
'available_number': avail[1],
|
||||
'available': avail[0] == Quota.AVAILABILITY_OK,
|
||||
'total_size': quota.size,
|
||||
|
||||
@@ -4,7 +4,7 @@ from decimal import Decimal
|
||||
import django_filters
|
||||
import pytz
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Prefetch, Q
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q
|
||||
from django.db.models.functions import Coalesce, Concat
|
||||
from django.http import FileResponse, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -26,12 +26,12 @@ from pretix.api.serializers.order import (
|
||||
InvoiceSerializer, OrderCreateSerializer, OrderPaymentCreateSerializer,
|
||||
OrderPaymentSerializer, OrderPositionSerializer,
|
||||
OrderRefundCreateSerializer, OrderRefundSerializer, OrderSerializer,
|
||||
PriceCalcSerializer,
|
||||
PriceCalcSerializer, SimulatedOrderSerializer,
|
||||
)
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
|
||||
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota,
|
||||
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent,
|
||||
TeamAPIToken, generate_position_secret, generate_secret,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
@@ -52,6 +52,7 @@ from pretix.base.signals import (
|
||||
order_modified, order_paid, order_placed, register_ticket_outputs,
|
||||
)
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.control.signals import order_search_filter_q
|
||||
|
||||
with scopes_disabled():
|
||||
class OrderFilter(FilterSet):
|
||||
@@ -60,11 +61,62 @@ with scopes_disabled():
|
||||
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
|
||||
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
|
||||
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
|
||||
subevent_after = django_filters.IsoDateTimeFilter(method='subevent_after_qs')
|
||||
search = django_filters.CharFilter(method='search_qs')
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
|
||||
|
||||
def subevent_after_qs(self, qs, name, value):
|
||||
qs = qs.annotate(
|
||||
has_se_after=Exists(
|
||||
OrderPosition.all.filter(
|
||||
subevent_id__in=SubEvent.objects.filter(
|
||||
Q(date_to__gt=value) | Q(date_from__gt=value, date_to__isnull=True), event=OuterRef(OuterRef('event_id'))
|
||||
).values_list('id'),
|
||||
order_id=OuterRef('pk'),
|
||||
)
|
||||
)
|
||||
).filter(has_se_after=True)
|
||||
return qs
|
||||
|
||||
def search_qs(self, qs, name, value):
|
||||
u = value
|
||||
if "-" in value:
|
||||
code = (Q(event__slug__icontains=u.rsplit("-", 1)[0])
|
||||
& Q(code__icontains=Order.normalize_code(u.rsplit("-", 1)[1])))
|
||||
else:
|
||||
code = Q(code__icontains=Order.normalize_code(u))
|
||||
|
||||
matching_invoices = Invoice.objects.filter(
|
||||
Q(invoice_no__iexact=u)
|
||||
| Q(invoice_no__iexact=u.zfill(5))
|
||||
| Q(full_invoice_no__iexact=u)
|
||||
).values_list('order_id', flat=True)
|
||||
|
||||
matching_positions = OrderPosition.objects.filter(
|
||||
Q(order=OuterRef('pk')) & Q(
|
||||
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
|
||||
| Q(secret__istartswith=u) | Q(voucher__code__icontains=u)
|
||||
)
|
||||
).values('id')
|
||||
|
||||
mainq = (
|
||||
code
|
||||
| Q(email__icontains=u)
|
||||
| Q(invoice_address__name_cached__icontains=u)
|
||||
| Q(invoice_address__company__icontains=u)
|
||||
| Q(pk__in=matching_invoices)
|
||||
| Q(comment__icontains=u)
|
||||
| Q(has_pos=True)
|
||||
)
|
||||
for recv, q in order_search_filter_q.send(sender=getattr(self, 'event', None), query=u):
|
||||
mainq = mainq | q
|
||||
return qs.annotate(has_pos=Exists(matching_positions)).filter(
|
||||
mainq
|
||||
)
|
||||
|
||||
|
||||
class OrderViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = OrderSerializer
|
||||
@@ -83,16 +135,19 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
|
||||
fqs = OrderFee.all
|
||||
else:
|
||||
fqs = OrderFee.objects
|
||||
qs = self.request.event.orders.prefetch_related(
|
||||
Prefetch('fees', queryset=fqs.all()),
|
||||
'payments', 'refunds', 'refunds__payment'
|
||||
).select_related(
|
||||
'invoice_address'
|
||||
)
|
||||
qs = self.request.event.orders
|
||||
if 'fees' not in self.request.GET.getlist('exclude'):
|
||||
if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
|
||||
fqs = OrderFee.all
|
||||
else:
|
||||
fqs = OrderFee.objects
|
||||
qs = qs.prefetch_related(Prefetch('fees', queryset=fqs.all()))
|
||||
if 'payments' not in self.request.GET.getlist('exclude'):
|
||||
qs = qs.prefetch_related('payments')
|
||||
if 'refunds' not in self.request.GET.getlist('exclude'):
|
||||
qs = qs.prefetch_related('refunds', 'refunds__payment')
|
||||
if 'invoice_address' not in self.request.GET.getlist('exclude'):
|
||||
qs = qs.select_related('invoice_address')
|
||||
|
||||
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
|
||||
opq = OrderPosition.all
|
||||
@@ -129,6 +184,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
return prov
|
||||
raise NotFound('Unknown output provider.')
|
||||
|
||||
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
|
||||
def list(self, request, **kwargs):
|
||||
date = serializers.DateTimeField().to_representation(now())
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
@@ -488,10 +544,12 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
self.perform_create(serializer)
|
||||
send_mail = serializer._send_mail
|
||||
order = serializer.instance
|
||||
serializer = OrderSerializer(order, context=serializer.context)
|
||||
if not order.pk:
|
||||
# Simulation
|
||||
serializer = SimulatedOrderSerializer(order, context=serializer.context)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
else:
|
||||
serializer = OrderSerializer(order, context=serializer.context)
|
||||
|
||||
order.log_action(
|
||||
'pretix.event.order.placed',
|
||||
@@ -743,7 +801,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
{
|
||||
"item": 2,
|
||||
"variation": null,
|
||||
"subevent": 3
|
||||
"subevent": 3,
|
||||
"tax_rule": 4,
|
||||
}
|
||||
|
||||
Sample output:
|
||||
@@ -797,7 +856,11 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
if data.get('subevent'):
|
||||
kwargs['subevent'] = data.get('subevent')
|
||||
|
||||
if data.get('tax_rule'):
|
||||
kwargs['tax_rule'] = data.get('tax_rule')
|
||||
|
||||
price = get_price(**kwargs)
|
||||
tr = kwargs.get('tax_rule', kwargs.get('item').tax_rule)
|
||||
with language(data.get('locale') or self.request.event.settings.locale):
|
||||
return Response({
|
||||
'gross': price.gross,
|
||||
@@ -806,6 +869,7 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
'rate': price.rate,
|
||||
'name': str(price.name),
|
||||
'tax': price.tax,
|
||||
'tax_rule': tr.pk if tr else None,
|
||||
})
|
||||
|
||||
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
|
||||
|
||||
@@ -168,9 +168,9 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
@app.task(base=TransactionAwareTask)
|
||||
@app.task(base=TransactionAwareTask, acks_late=True)
|
||||
def notify_webhooks(logentry_id: int):
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
logentry = LogEntry.all.select_related('event', 'event__organizer').get(id=logentry_id)
|
||||
|
||||
if not logentry.organizer:
|
||||
return # We need to know the organizer
|
||||
@@ -205,7 +205,7 @@ def notify_webhooks(logentry_id: int):
|
||||
send_webhook.apply_async(args=(logentry_id, notification_type.action_type, wh.pk))
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=9)
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=9, acks_late=True)
|
||||
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
|
||||
# 9 retries with 2**(2*x) timing is roughly 72 hours
|
||||
with scopes_disabled():
|
||||
|
||||
@@ -12,7 +12,9 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import get_language, gettext_lazy as _
|
||||
from inlinestyler.utils import inline_css
|
||||
|
||||
from pretix.base.i18n import LazyCurrencyNumber, LazyDate, LazyNumber
|
||||
from pretix.base.i18n import (
|
||||
LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber,
|
||||
)
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import (
|
||||
@@ -258,9 +260,30 @@ def _placeholder_payment(order, payment):
|
||||
return str(payment.payment_provider.order_pending_mail_render(order))
|
||||
|
||||
|
||||
def get_best_name(position_or_address, parts=False):
|
||||
"""
|
||||
Return the best name we got for either an invoice address or an order position, falling back to the respective other
|
||||
"""
|
||||
from pretix.base.models import InvoiceAddress, OrderPosition
|
||||
if isinstance(position_or_address, InvoiceAddress):
|
||||
if position_or_address.name:
|
||||
return position_or_address.name_parts if parts else position_or_address.name
|
||||
position_or_address = position_or_address.order.positions.exclude(attendee_name_cached="").exclude(attendee_name_cached__isnull=True).first()
|
||||
|
||||
if isinstance(position_or_address, OrderPosition):
|
||||
if position_or_address.attendee_name:
|
||||
return position_or_address.attendee_name_parts if parts else position_or_address.attendee_name
|
||||
elif position_or_address.order:
|
||||
try:
|
||||
return position_or_address.order.invoice_address.name_parts if parts else position_or_address.order.invoice_address.name
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
return {} if parts else ""
|
||||
|
||||
|
||||
@receiver(register_mail_placeholders, dispatch_uid="pretixbase_register_mail_placeholders")
|
||||
def base_placeholders(sender, **kwargs):
|
||||
from pretix.base.models import InvoiceAddress
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
ph = [
|
||||
@@ -294,9 +317,8 @@ def base_placeholders(sender, **kwargs):
|
||||
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'expire_date', ['event', 'order'], lambda event, order: LazyDate(order.expires.astimezone(event.timezone)),
|
||||
'expire_date', ['event', 'order'], lambda event, order: LazyExpiresDate(order.expires.astimezone(event.timezone)),
|
||||
lambda event: LazyDate(now() + timedelta(days=15))
|
||||
# TODO: This used to be "date" in some placeholders, add a migration!
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
@@ -315,6 +337,51 @@ def base_placeholders(sender, **kwargs):
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.modify', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.modify', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_products_change', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.change', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.change', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_cancel', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.cancel', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.cancel', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['event', 'position'], lambda event, position: build_absolute_uri(
|
||||
event,
|
||||
@@ -429,11 +496,7 @@ def base_placeholders(sender, **kwargs):
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'name', ['position_or_address'],
|
||||
lambda position_or_address: (
|
||||
position_or_address.name
|
||||
if isinstance(position_or_address, InvoiceAddress)
|
||||
else position_or_address.attendee_name
|
||||
),
|
||||
get_best_name,
|
||||
_('John Doe'),
|
||||
),
|
||||
]
|
||||
@@ -448,11 +511,7 @@ def base_placeholders(sender, **kwargs):
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'name_%s' % f, ['position_or_address'],
|
||||
lambda position_or_address, f=f: (
|
||||
position_or_address.name_parts.get(f, '')
|
||||
if isinstance(position_or_address, InvoiceAddress)
|
||||
else position_or_address.attendee_name_parts.get(f, '')
|
||||
),
|
||||
lambda position_or_address, f=f: get_best_name(position_or_address, parts=True).get(f, ''),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import io
|
||||
import tempfile
|
||||
from collections import OrderedDict
|
||||
from collections import OrderedDict, namedtuple
|
||||
from decimal import Decimal
|
||||
from typing import Tuple
|
||||
|
||||
from defusedcsv import csv
|
||||
from django import forms
|
||||
from django.db.models import QuerySet
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell.cell import KNOWN_TYPES
|
||||
|
||||
from pretix.base.models import Event
|
||||
|
||||
|
||||
class BaseExporter:
|
||||
"""
|
||||
This is the base class for all data exporters
|
||||
"""
|
||||
|
||||
def __init__(self, event):
|
||||
def __init__(self, event, progress_callback=lambda v: None):
|
||||
self.event = event
|
||||
self.progress_callback = progress_callback
|
||||
self.is_multievent = isinstance(event, QuerySet)
|
||||
if isinstance(event, QuerySet):
|
||||
self.events = event
|
||||
self.event = None
|
||||
else:
|
||||
self.events = Event.objects.filter(pk=event.pk)
|
||||
|
||||
def __str__(self):
|
||||
return self.identifier
|
||||
@@ -85,6 +95,7 @@ class BaseExporter:
|
||||
|
||||
|
||||
class ListExporter(BaseExporter):
|
||||
ProgressSetTotal = namedtuple('ProgressSetTotal', 'total')
|
||||
|
||||
@property
|
||||
def export_form_fields(self) -> dict:
|
||||
@@ -117,35 +128,66 @@ class ListExporter(BaseExporter):
|
||||
|
||||
def _render_csv(self, form_data, output_file=None, **kwargs):
|
||||
if output_file:
|
||||
if 'b' in output_file.mode:
|
||||
output_file = io.TextIOWrapper(output_file, encoding='utf-8', newline='')
|
||||
writer = csv.writer(output_file, **kwargs)
|
||||
total = 0
|
||||
counter = 0
|
||||
for line in self.iterate_list(form_data):
|
||||
if isinstance(line, self.ProgressSetTotal):
|
||||
total = line.total
|
||||
continue
|
||||
line = [
|
||||
localize(f) if isinstance(f, Decimal) else f
|
||||
for f in line
|
||||
]
|
||||
if total:
|
||||
counter += 1
|
||||
if counter % max(10, total // 100) == 0:
|
||||
self.progress_callback(counter / total * 100)
|
||||
writer.writerow(line)
|
||||
return self.get_filename() + '.csv', 'text/csv', None
|
||||
else:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, **kwargs)
|
||||
total = 0
|
||||
counter = 0
|
||||
for line in self.iterate_list(form_data):
|
||||
if isinstance(line, self.ProgressSetTotal):
|
||||
total = line.total
|
||||
continue
|
||||
line = [
|
||||
localize(f) if isinstance(f, Decimal) else f
|
||||
for f in line
|
||||
]
|
||||
if total:
|
||||
counter += 1
|
||||
if counter % max(10, total // 100) == 0:
|
||||
self.progress_callback(counter / total * 100)
|
||||
writer.writerow(line)
|
||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
def _render_xlsx(self, form_data, output_file=None):
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
wb = Workbook(write_only=True)
|
||||
ws = wb.create_sheet()
|
||||
try:
|
||||
ws.title = str(self.verbose_name)
|
||||
except:
|
||||
pass
|
||||
total = 0
|
||||
counter = 0
|
||||
for i, line in enumerate(self.iterate_list(form_data)):
|
||||
for j, val in enumerate(line):
|
||||
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
|
||||
if isinstance(line, self.ProgressSetTotal):
|
||||
total = line.total
|
||||
continue
|
||||
ws.append([
|
||||
str(val) if not isinstance(val, KNOWN_TYPES) else val
|
||||
for val in line
|
||||
])
|
||||
if total:
|
||||
counter += 1
|
||||
if counter % max(10, total // 100) == 0:
|
||||
self.progress_callback(counter / total * 100)
|
||||
|
||||
if output_file:
|
||||
wb.save(output_file)
|
||||
@@ -203,35 +245,63 @@ class MultiSheetListExporter(ListExporter):
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
def _render_sheet_csv(self, form_data, sheet, output_file=None, **kwargs):
|
||||
total = 0
|
||||
counter = 0
|
||||
if output_file:
|
||||
if 'b' in output_file.mode:
|
||||
output_file = io.TextIOWrapper(output_file, encoding='utf-8', newline='')
|
||||
writer = csv.writer(output_file, **kwargs)
|
||||
for line in self.iterate_sheet(form_data, sheet):
|
||||
if isinstance(line, self.ProgressSetTotal):
|
||||
total = line.total
|
||||
continue
|
||||
line = [
|
||||
localize(f) if isinstance(f, Decimal) else f
|
||||
for f in line
|
||||
]
|
||||
writer.writerow(line)
|
||||
if total:
|
||||
counter += 1
|
||||
if counter % max(10, total // 100) == 0:
|
||||
self.progress_callback(counter / total * 100)
|
||||
return self.get_filename() + '.csv', 'text/csv', None
|
||||
else:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, **kwargs)
|
||||
for line in self.iterate_sheet(form_data, sheet):
|
||||
if isinstance(line, self.ProgressSetTotal):
|
||||
total = line.total
|
||||
continue
|
||||
line = [
|
||||
localize(f) if isinstance(f, Decimal) else f
|
||||
for f in line
|
||||
]
|
||||
writer.writerow(line)
|
||||
if total:
|
||||
counter += 1
|
||||
if counter % max(10, total // 100) == 0:
|
||||
self.progress_callback(counter / total * 100)
|
||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
def _render_xlsx(self, form_data, output_file=None):
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
wb.remove(ws)
|
||||
for s, l in self.sheets:
|
||||
wb = Workbook(write_only=True)
|
||||
n_sheets = len(self.sheets)
|
||||
for i_sheet, (s, l) in enumerate(self.sheets):
|
||||
ws = wb.create_sheet(str(l))
|
||||
total = 0
|
||||
counter = 0
|
||||
for i, line in enumerate(self.iterate_sheet(form_data, sheet=s)):
|
||||
for j, val in enumerate(line):
|
||||
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
|
||||
if isinstance(line, self.ProgressSetTotal):
|
||||
total = line.total
|
||||
continue
|
||||
ws.append([
|
||||
str(val) if not isinstance(val, KNOWN_TYPES) else val
|
||||
for val in line
|
||||
])
|
||||
if total:
|
||||
counter += 1
|
||||
if counter % max(10, total // 100) == 0:
|
||||
self.progress_callback(counter / total * 100 / n_sheets + 100 / n_sheets * i_sheet)
|
||||
|
||||
if output_file:
|
||||
wb.save(output_file)
|
||||
|
||||
@@ -1,81 +1,33 @@
|
||||
import os
|
||||
import tempfile
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
from zipfile import ZipFile
|
||||
|
||||
import dateutil.parser
|
||||
from django import forms
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery, Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext, gettext_lazy as _, pgettext
|
||||
|
||||
from pretix.base.models import OrderPayment
|
||||
from pretix.base.models import Invoice, InvoiceLine, OrderPayment
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ...control.forms.filter import get_all_payment_providers
|
||||
from ...helpers import GroupConcat
|
||||
from ...helpers.iter import chunked_iterable
|
||||
from ..exporter import BaseExporter, MultiSheetListExporter
|
||||
from ..services.invoices import invoice_pdf_task
|
||||
from ..signals import register_data_exporters
|
||||
from ..signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
|
||||
|
||||
class InvoiceExporter(BaseExporter):
|
||||
identifier = 'invoices'
|
||||
verbose_name = _('All invoices')
|
||||
|
||||
def render(self, form_data: dict, output_file=None):
|
||||
qs = self.event.invoices.filter(shredded=False)
|
||||
|
||||
if form_data.get('payment_provider'):
|
||||
qs = qs.annotate(
|
||||
has_payment_with_provider=Exists(
|
||||
OrderPayment.objects.filter(
|
||||
Q(order=OuterRef('order_id')) & Q(provider=form_data.get('payment_provider'))
|
||||
)
|
||||
)
|
||||
)
|
||||
qs = qs.filter(has_payment_with_provider=1)
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(date__gte=date_value)
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(date__lte=date_value)
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
any = False
|
||||
with ZipFile(output_file or os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for i in qs:
|
||||
try:
|
||||
if not i.file:
|
||||
invoice_pdf_task.apply(args=(i.pk,))
|
||||
i.refresh_from_db()
|
||||
i.file.open('rb')
|
||||
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
|
||||
any = True
|
||||
i.file.close()
|
||||
except FileNotFoundError:
|
||||
invoice_pdf_task.apply(args=(i.pk,))
|
||||
i.refresh_from_db()
|
||||
i.file.open('rb')
|
||||
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
|
||||
any = True
|
||||
i.file.close()
|
||||
|
||||
if not any:
|
||||
return None
|
||||
|
||||
if output_file:
|
||||
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', None
|
||||
else:
|
||||
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
|
||||
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read()
|
||||
class InvoiceExporterMixin:
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
def invoice_exporter_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date_from',
|
||||
@@ -99,6 +51,8 @@ class InvoiceExporter(BaseExporter):
|
||||
label=_('Payment provider'),
|
||||
choices=[
|
||||
('', _('All payment providers')),
|
||||
] + get_all_payment_providers() if self.is_multievent else [
|
||||
('', _('All payment providers')),
|
||||
] + [
|
||||
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
|
||||
],
|
||||
@@ -111,7 +65,339 @@ class InvoiceExporter(BaseExporter):
|
||||
]
|
||||
)
|
||||
|
||||
def invoices_queryset(self, form_data: dict):
|
||||
qs = Invoice.objects.filter(event__in=self.events)
|
||||
|
||||
if form_data.get('payment_provider'):
|
||||
qs = qs.annotate(
|
||||
has_payment_with_provider=Exists(
|
||||
OrderPayment.objects.filter(
|
||||
Q(order=OuterRef('order_id')) & Q(provider=form_data.get('payment_provider'))
|
||||
)
|
||||
)
|
||||
)
|
||||
qs = qs.filter(has_payment_with_provider=1)
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(date__gte=date_value)
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(date__lte=date_value)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
|
||||
identifier = 'invoices'
|
||||
verbose_name = _('All invoices')
|
||||
|
||||
def render(self, form_data: dict, output_file=None):
|
||||
qs = self.invoices_queryset(form_data).filter(shredded=False)
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
total = qs.count()
|
||||
|
||||
if not total:
|
||||
return None
|
||||
|
||||
counter = 0
|
||||
with ZipFile(output_file or os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for i in qs.iterator():
|
||||
try:
|
||||
if not i.file:
|
||||
invoice_pdf_task.apply(args=(i.pk,))
|
||||
i.refresh_from_db()
|
||||
i.file.open('rb')
|
||||
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
|
||||
i.file.close()
|
||||
except FileNotFoundError:
|
||||
invoice_pdf_task.apply(args=(i.pk,))
|
||||
i.refresh_from_db()
|
||||
i.file.open('rb')
|
||||
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
|
||||
i.file.close()
|
||||
counter += 1
|
||||
if total and counter % max(10, total // 100) == 0:
|
||||
self.progress_callback(counter / total * 100)
|
||||
|
||||
if self.is_multievent:
|
||||
filename = '{}_invoices.zip'.format(self.events.first().organizer.slug)
|
||||
else:
|
||||
filename = '{}_invoices.zip'.format(self.event.slug)
|
||||
|
||||
if output_file:
|
||||
return filename, 'application/zip', None
|
||||
else:
|
||||
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
|
||||
return filename, 'application/zip', zipf.read()
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
return self.invoice_exporter_form_fields
|
||||
|
||||
|
||||
class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
identifier = 'invoicedata'
|
||||
verbose_name = _('Invoice data')
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
return self.invoice_exporter_form_fields
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
return (
|
||||
('invoices', _('Invoices')),
|
||||
('lines', _('Invoice lines')),
|
||||
)
|
||||
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
_ = gettext
|
||||
if sheet == 'invoices':
|
||||
yield [
|
||||
_('Invoice number'),
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
_('E-mail address'),
|
||||
_('Invoice type'),
|
||||
_('Cancellation of'),
|
||||
_('Language'),
|
||||
_('Invoice sender:') + ' ' + _('Name'),
|
||||
_('Invoice sender:') + ' ' + _('Address'),
|
||||
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||
_('Invoice sender:') + ' ' + _('City'),
|
||||
_('Invoice sender:') + ' ' + _('Country'),
|
||||
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Company'),
|
||||
_('Invoice recipient:') + ' ' + _('Name'),
|
||||
_('Invoice recipient:') + ' ' + _('Street address'),
|
||||
_('Invoice recipient:') + ' ' + _('ZIP code'),
|
||||
_('Invoice recipient:') + ' ' + _('City'),
|
||||
_('Invoice recipient:') + ' ' + _('Country'),
|
||||
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
|
||||
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||
_('Reverse charge'),
|
||||
_('Shown foreign currency'),
|
||||
_('Foreign currency rate'),
|
||||
_('Total value (with taxes)'),
|
||||
_('Total value (without taxes)'),
|
||||
_('Payment matching IDs'),
|
||||
_('Payment providers'),
|
||||
]
|
||||
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('provider', delimiter=',')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
|
||||
base_qs = self.invoices_queryset(form_data)\
|
||||
|
||||
qs = base_qs.select_related(
|
||||
'order', 'refers'
|
||||
).prefetch_related('order__payments').annotate(
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
total_gross=Subquery(
|
||||
InvoiceLine.objects.filter(
|
||||
invoice=OuterRef('pk')
|
||||
).order_by().values('invoice').annotate(
|
||||
s=Sum('gross_value')
|
||||
).values('s')
|
||||
),
|
||||
total_net=Subquery(
|
||||
InvoiceLine.objects.filter(
|
||||
invoice=OuterRef('pk')
|
||||
).order_by().values('invoice').annotate(
|
||||
s=Sum(F('gross_value') - F('tax_value'))
|
||||
).values('s')
|
||||
)
|
||||
)
|
||||
|
||||
all_ids = base_qs.order_by('full_invoice_no').values_list('pk', flat=True)
|
||||
yield self.ProgressSetTotal(total=len(all_ids))
|
||||
for ids in chunked_iterable(all_ids, 1000):
|
||||
invs = sorted(qs.filter(id__in=ids), key=lambda k: ids.index(k.pk))
|
||||
|
||||
for i in invs:
|
||||
pmis = []
|
||||
for p in i.order.payments.all():
|
||||
if p.state in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_CREATED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_REFUNDED):
|
||||
pprov = p.payment_provider
|
||||
if pprov:
|
||||
mid = pprov.matching_id(p)
|
||||
if mid:
|
||||
pmis.append(mid)
|
||||
pmi = '\n'.join(pmis)
|
||||
yield [
|
||||
i.full_invoice_no,
|
||||
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||
i.order.code,
|
||||
i.order.email,
|
||||
_('Cancellation') if i.is_cancellation else _('Invoice'),
|
||||
i.refers.full_invoice_no if i.refers else '',
|
||||
i.locale,
|
||||
i.invoice_from_name,
|
||||
i.invoice_from,
|
||||
i.invoice_from_zipcode,
|
||||
i.invoice_from_city,
|
||||
i.invoice_from_country,
|
||||
i.invoice_from_tax_id,
|
||||
i.invoice_from_vat_id,
|
||||
i.invoice_to_company,
|
||||
i.invoice_to_name,
|
||||
i.invoice_to_street or i.invoice_to,
|
||||
i.invoice_to_zipcode,
|
||||
i.invoice_to_city,
|
||||
i.invoice_to_country,
|
||||
i.invoice_to_state,
|
||||
i.invoice_to_vat_id,
|
||||
i.invoice_to_beneficiary,
|
||||
i.internal_reference,
|
||||
_('Yes') if i.reverse_charge else _('No'),
|
||||
i.foreign_currency_display,
|
||||
i.foreign_currency_rate,
|
||||
i.total_gross if i.total_gross else Decimal('0.00'),
|
||||
Decimal(i.total_net if i.total_net else '0.00').quantize(Decimal('0.01')),
|
||||
pmi,
|
||||
', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((i.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
])
|
||||
]
|
||||
elif sheet == 'lines':
|
||||
yield [
|
||||
_('Invoice number'),
|
||||
_('Line number'),
|
||||
_('Description'),
|
||||
_('Gross price'),
|
||||
_('Net price'),
|
||||
_('Tax value'),
|
||||
_('Tax rate'),
|
||||
_('Tax name'),
|
||||
_('Event start date'),
|
||||
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
_('E-mail address'),
|
||||
_('Invoice type'),
|
||||
_('Cancellation of'),
|
||||
_('Invoice sender:') + ' ' + _('Name'),
|
||||
_('Invoice sender:') + ' ' + _('Address'),
|
||||
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||
_('Invoice sender:') + ' ' + _('City'),
|
||||
_('Invoice sender:') + ' ' + _('Country'),
|
||||
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Company'),
|
||||
_('Invoice recipient:') + ' ' + _('Name'),
|
||||
_('Invoice recipient:') + ' ' + _('Street address'),
|
||||
_('Invoice recipient:') + ' ' + _('ZIP code'),
|
||||
_('Invoice recipient:') + ' ' + _('City'),
|
||||
_('Invoice recipient:') + ' ' + _('Country'),
|
||||
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
|
||||
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||
_('Payment providers'),
|
||||
]
|
||||
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('invoice__order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('provider', delimiter=',')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
|
||||
qs = InvoiceLine.objects.annotate(
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
).filter(
|
||||
invoice__in=self.invoices_queryset(form_data)
|
||||
).order_by('invoice__full_invoice_no', 'position').select_related(
|
||||
'invoice', 'invoice__order', 'invoice__refers'
|
||||
)
|
||||
yield self.ProgressSetTotal(total=qs.count())
|
||||
|
||||
for l in qs.iterator():
|
||||
i = l.invoice
|
||||
yield [
|
||||
i.full_invoice_no,
|
||||
l.position + 1,
|
||||
l.description.replace("<br />", " - "),
|
||||
l.gross_value,
|
||||
l.net_value,
|
||||
l.tax_value,
|
||||
l.tax_rate,
|
||||
l.tax_name,
|
||||
date_format(l.event_date_from, "SHORT_DATE_FORMAT") if l.event_date_from else "",
|
||||
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||
i.order.code,
|
||||
i.order.email,
|
||||
_('Cancellation') if i.is_cancellation else _('Invoice'),
|
||||
i.refers.full_invoice_no if i.refers else '',
|
||||
i.invoice_from_name,
|
||||
i.invoice_from,
|
||||
i.invoice_from_zipcode,
|
||||
i.invoice_from_city,
|
||||
i.invoice_from_country,
|
||||
i.invoice_from_tax_id,
|
||||
i.invoice_from_vat_id,
|
||||
i.invoice_to_company,
|
||||
i.invoice_to_name,
|
||||
i.invoice_to_street or i.invoice_to,
|
||||
i.invoice_to_zipcode,
|
||||
i.invoice_to_city,
|
||||
i.invoice_to_country,
|
||||
i.invoice_to_state,
|
||||
i.invoice_to_vat_id,
|
||||
i.invoice_to_beneficiary,
|
||||
i.internal_reference,
|
||||
', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((l.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
])
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def providers(self):
|
||||
return dict(get_all_payment_providers())
|
||||
|
||||
def get_filename(self):
|
||||
if self.is_multievent:
|
||||
return '{}_invoices'.format(self.events.first().organizer.slug)
|
||||
else:
|
||||
return '{}_invoices'.format(self.event.slug)
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_invoices")
|
||||
def register_invoice_export(sender, **kwargs):
|
||||
return InvoiceExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_invoices")
|
||||
def register_multievent_invoice_export(sender, **kwargs):
|
||||
return InvoiceExporter
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_invoicedata")
|
||||
def register_invoicedata_exporter(sender, **kwargs):
|
||||
return InvoiceDataExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_invoicedata")
|
||||
def register_multievent_invoicedatae_xporter(sender, **kwargs):
|
||||
return InvoiceDataExporter
|
||||
|
||||
@@ -8,7 +8,9 @@ from pretix.base.models import OrderPosition
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..models import Order
|
||||
from ..signals import register_data_exporters
|
||||
from ..signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
|
||||
|
||||
class MailExporter(BaseExporter):
|
||||
@@ -16,14 +18,18 @@ class MailExporter(BaseExporter):
|
||||
verbose_name = _('Email addresses (text file)')
|
||||
|
||||
def render(self, form_data: dict):
|
||||
qs = self.event.orders.filter(status__in=form_data['status'])
|
||||
qs = Order.objects.filter(event__in=self.events, status__in=form_data['status']).prefetch_related('event')
|
||||
addrs = qs.values('email')
|
||||
pos = OrderPosition.objects.filter(
|
||||
order__event=self.event, order__status__in=form_data['status']
|
||||
order__event__in=self.events, order__status__in=form_data['status']
|
||||
).values('attendee_email')
|
||||
data = "\r\n".join(set(a['email'] for a in addrs if a['email'])
|
||||
| set(a['attendee_email'] for a in pos if a['attendee_email']))
|
||||
return '{}_pretixemails.txt'.format(self.event.slug), 'text/plain', data.encode("utf-8")
|
||||
|
||||
if self.is_multievent:
|
||||
return '{}_pretixemails.txt'.format(self.events.first().organizer.slug), 'text/plain', data.encode("utf-8")
|
||||
else:
|
||||
return '{}_pretixemails.txt'.format(self.event.slug), 'text/plain', data.encode("utf-8")
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
@@ -44,3 +50,8 @@ class MailExporter(BaseExporter):
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_mail")
|
||||
def register_mail_export(sender, **kwargs):
|
||||
return MailExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_mail")
|
||||
def register_multievent_mail_export(sender, **kwargs):
|
||||
return MailExporter
|
||||
|
||||
@@ -4,26 +4,37 @@ from decimal import Decimal
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.db.models import (
|
||||
Count, DateTimeField, F, IntegerField, Max, OuterRef, Subquery, Sum,
|
||||
CharField, Count, DateTimeField, IntegerField, Max, OuterRef, Subquery,
|
||||
Sum,
|
||||
)
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext
|
||||
|
||||
from pretix.base.models import (
|
||||
GiftCard, InvoiceAddress, InvoiceLine, Order, OrderPosition, Question,
|
||||
GiftCard, Invoice, InvoiceAddress, Order, OrderPosition, Question,
|
||||
)
|
||||
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from ...control.forms.filter import get_all_payment_providers
|
||||
from ...helpers import GroupConcat
|
||||
from ...helpers.iter import chunked_iterable
|
||||
from ..exporter import ListExporter, MultiSheetListExporter
|
||||
from ..signals import register_data_exporters
|
||||
from ..signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
|
||||
|
||||
class OrderListExporter(MultiSheetListExporter):
|
||||
identifier = 'orderlist'
|
||||
verbose_name = gettext_lazy('Order data')
|
||||
|
||||
@cached_property
|
||||
def providers(self):
|
||||
return dict(get_all_payment_providers())
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
return (
|
||||
@@ -49,13 +60,13 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
tax_rates = set(
|
||||
a for a
|
||||
in OrderFee.objects.filter(
|
||||
order__event=self.event
|
||||
order__event__in=self.events
|
||||
).values_list('tax_rate', flat=True).distinct().order_by()
|
||||
)
|
||||
tax_rates |= set(
|
||||
a for a
|
||||
in OrderPosition.objects.filter(
|
||||
order__event=self.event
|
||||
order__event__in=self.events
|
||||
).values_list('tax_rate', flat=True).distinct().order_by()
|
||||
)
|
||||
tax_rates = sorted(tax_rates)
|
||||
@@ -69,9 +80,11 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
elif sheet == 'fees':
|
||||
return self.iterate_fees(form_data)
|
||||
|
||||
def iterate_orders(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
@cached_property
|
||||
def event_object_cache(self):
|
||||
return {e.pk: e for e in self.events}
|
||||
|
||||
def iterate_orders(self, form_data: dict):
|
||||
p_date = OrderPayment.objects.filter(
|
||||
order=OuterRef('pk'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
@@ -81,24 +94,42 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('pk'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('provider', delimiter=',')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
i_numbers = Invoice.objects.filter(
|
||||
order=OuterRef('pk'),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('full_invoice_no', delimiter=', ')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
|
||||
s = OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(k=Count('id')).values('k')
|
||||
qs = self.event.orders.annotate(
|
||||
qs = Order.objects.filter(event__in=self.events).annotate(
|
||||
payment_date=Subquery(p_date, output_field=DateTimeField()),
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
invoice_numbers=Subquery(i_numbers, output_field=CharField()),
|
||||
pcnt=Subquery(s, output_field=IntegerField())
|
||||
).select_related('invoice_address').prefetch_related('invoices')
|
||||
).select_related('invoice_address')
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(status=Order.STATUS_PAID)
|
||||
tax_rates = self._get_all_tax_rates(qs)
|
||||
|
||||
headers = [
|
||||
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
|
||||
_('Company'), _('Name'),
|
||||
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
|
||||
_('Order time'), _('Company'), _('Name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(label)
|
||||
headers += [
|
||||
@@ -118,6 +149,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('Requires special attention'))
|
||||
headers.append(_('Comment'))
|
||||
headers.append(_('Positions'))
|
||||
headers.append(_('Payment providers'))
|
||||
|
||||
yield headers
|
||||
|
||||
@@ -138,20 +170,25 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
)
|
||||
}
|
||||
|
||||
for order in qs.order_by('datetime'):
|
||||
yield self.ProgressSetTotal(total=qs.count())
|
||||
for order in qs.order_by('datetime').iterator():
|
||||
tz = pytz.timezone(self.event_object_cache[order.event_id].settings.timezone)
|
||||
|
||||
row = [
|
||||
self.event_object_cache[order.event_id].slug,
|
||||
order.code,
|
||||
order.total,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
|
||||
]
|
||||
try:
|
||||
row += [
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
order.invoice_address.name_parts.get(k, '')
|
||||
@@ -166,7 +203,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
|
||||
|
||||
row += [
|
||||
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
||||
@@ -188,27 +225,42 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
taxrate_values['taxsum'] + fee_taxrate_values['taxsum'],
|
||||
]
|
||||
|
||||
row.append(', '.join([i.number for i in order.invoices.all()]))
|
||||
row.append(order.invoice_numbers)
|
||||
row.append(order.sales_channel)
|
||||
row.append(_('Yes') if order.checkin_attention else _('No'))
|
||||
row.append(order.comment or "")
|
||||
row.append(order.pcnt)
|
||||
row.append(', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((order.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
]))
|
||||
yield row
|
||||
|
||||
def iterate_fees(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('provider', delimiter=',')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
qs = OrderFee.objects.filter(
|
||||
order__event=self.event,
|
||||
order__event__in=self.events,
|
||||
).annotate(
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
).select_related('order', 'order__invoice_address', 'tax_rule')
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||
|
||||
headers = [
|
||||
_('Event slug'),
|
||||
_('Order code'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
_('Order date'),
|
||||
_('Order time'),
|
||||
_('Fee type'),
|
||||
_('Description'),
|
||||
_('Price'),
|
||||
@@ -218,23 +270,28 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Company'),
|
||||
_('Invoice address name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Invoice address name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
|
||||
]
|
||||
|
||||
headers.append(_('Payment providers'))
|
||||
yield headers
|
||||
|
||||
for op in qs.order_by('order__datetime'):
|
||||
yield self.ProgressSetTotal(total=qs.count())
|
||||
for op in qs.order_by('order__datetime').iterator():
|
||||
order = op.order
|
||||
tz = pytz.timezone(order.event.settings.timezone)
|
||||
row = [
|
||||
self.event_object_cache[order.event_id].slug,
|
||||
order.code,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
|
||||
op.get_fee_type_display(),
|
||||
op.description,
|
||||
op.value,
|
||||
@@ -247,7 +304,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
order.invoice_address.name_parts.get(k, '')
|
||||
@@ -262,14 +319,28 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
|
||||
row.append(', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
]))
|
||||
yield row
|
||||
|
||||
def iterate_positions(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.event,
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('provider', delimiter=',')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
base_qs = OrderPosition.objects.filter(
|
||||
order__event__in=self.events,
|
||||
)
|
||||
qs = base_qs.annotate(
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
).select_related(
|
||||
'order', 'order__invoice_address', 'item', 'variation',
|
||||
'voucher', 'tax_rule'
|
||||
@@ -279,14 +350,18 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||
|
||||
has_subevents = self.events.filter(has_subevents=True).exists()
|
||||
|
||||
headers = [
|
||||
_('Event slug'),
|
||||
_('Order code'),
|
||||
_('Position ID'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
_('Order date'),
|
||||
_('Order time'),
|
||||
]
|
||||
if self.event.has_subevents:
|
||||
if has_subevents:
|
||||
headers.append(pgettext('subevent', 'Date'))
|
||||
headers.append(_('Start date'))
|
||||
headers.append(_('End date'))
|
||||
@@ -299,8 +374,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Tax value'),
|
||||
_('Attendee name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Attendee name') + ': ' + str(label))
|
||||
headers += [
|
||||
@@ -314,7 +389,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Voucher'),
|
||||
_('Pseudonymization ID'),
|
||||
]
|
||||
questions = list(self.event.questions.all())
|
||||
|
||||
questions = list(Question.objects.filter(event__in=self.events))
|
||||
options = {}
|
||||
for q in questions:
|
||||
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
@@ -328,100 +404,126 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Company'),
|
||||
_('Invoice address name'),
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Invoice address name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
|
||||
]
|
||||
headers.append(_('Sales channel'))
|
||||
headers += [
|
||||
_('Sales channel'), _('Order locale'),
|
||||
_('Payment providers'),
|
||||
]
|
||||
|
||||
yield headers
|
||||
|
||||
for op in qs.order_by('order__datetime', 'positionid'):
|
||||
order = op.order
|
||||
row = [
|
||||
order.code,
|
||||
op.positionid,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
]
|
||||
if self.event.has_subevents:
|
||||
row.append(op.subevent.name)
|
||||
row.append(op.subevent.date_from.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
|
||||
if op.subevent.date_to:
|
||||
row.append(op.subevent.date_to.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
|
||||
else:
|
||||
row.append('')
|
||||
row += [
|
||||
str(op.item),
|
||||
str(op.variation) if op.variation else '',
|
||||
op.price,
|
||||
op.tax_rate,
|
||||
str(op.tax_rule) if op.tax_rule else '',
|
||||
op.tax_value,
|
||||
op.attendee_name,
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
op.attendee_name_parts.get(k, '')
|
||||
)
|
||||
row += [
|
||||
op.attendee_email,
|
||||
op.company or '',
|
||||
op.street or '',
|
||||
op.zipcode or '',
|
||||
op.city or '',
|
||||
op.country if op.country else '',
|
||||
op.state or '',
|
||||
op.voucher.code if op.voucher else '',
|
||||
op.pseudonymization_id,
|
||||
]
|
||||
acache = {}
|
||||
for a in op.answers.all():
|
||||
# We do not want to localize Date, Time and Datetime question answers, as those can lead
|
||||
# to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French).
|
||||
if a.question.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
acache[a.question_id] = set(o.pk for o in a.options.all())
|
||||
elif a.question.type in Question.UNLOCALIZED_TYPES:
|
||||
acache[a.question_id] = a.answer
|
||||
else:
|
||||
acache[a.question_id] = str(a)
|
||||
for q in questions:
|
||||
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
for o in options[q.pk]:
|
||||
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
|
||||
else:
|
||||
row.append(acache.get(q.pk, ''))
|
||||
all_ids = list(base_qs.order_by('order__datetime', 'positionid').values_list('pk', flat=True))
|
||||
yield self.ProgressSetTotal(total=len(all_ids))
|
||||
for ids in chunked_iterable(all_ids, 1000):
|
||||
ops = sorted(qs.filter(id__in=ids), key=lambda k: ids.index(k.pk))
|
||||
|
||||
try:
|
||||
row += [
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
for op in ops:
|
||||
order = op.order
|
||||
tz = pytz.timezone(self.event_object_cache[order.event_id].settings.timezone)
|
||||
row = [
|
||||
self.event_object_cache[order.event_id].slug,
|
||||
order.code,
|
||||
op.positionid,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
if has_subevents:
|
||||
if op.subevent:
|
||||
row.append(op.subevent.name)
|
||||
row.append(op.subevent.date_from.astimezone(self.event_object_cache[order.event_id].timezone).strftime('%Y-%m-%d %H:%M:%S'))
|
||||
if op.subevent.date_to:
|
||||
row.append(op.subevent.date_to.astimezone(self.event_object_cache[order.event_id].timezone).strftime('%Y-%m-%d %H:%M:%S'))
|
||||
else:
|
||||
row.append('')
|
||||
else:
|
||||
row.append('')
|
||||
row.append('')
|
||||
row.append('')
|
||||
row += [
|
||||
str(op.item),
|
||||
str(op.variation) if op.variation else '',
|
||||
op.price,
|
||||
op.tax_rate,
|
||||
str(op.tax_rule) if op.tax_rule else '',
|
||||
op.tax_value,
|
||||
op.attendee_name,
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
order.invoice_address.name_parts.get(k, '')
|
||||
op.attendee_name_parts.get(k, '')
|
||||
)
|
||||
row += [
|
||||
order.invoice_address.street,
|
||||
order.invoice_address.zipcode,
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.state,
|
||||
order.invoice_address.vat_id,
|
||||
op.attendee_email,
|
||||
op.company or '',
|
||||
op.street or '',
|
||||
op.zipcode or '',
|
||||
op.city or '',
|
||||
op.country if op.country else '',
|
||||
op.state or '',
|
||||
op.voucher.code if op.voucher else '',
|
||||
op.pseudonymization_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
row.append(order.sales_channel)
|
||||
yield row
|
||||
acache = {}
|
||||
for a in op.answers.all():
|
||||
# We do not want to localize Date, Time and Datetime question answers, as those can lead
|
||||
# to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French).
|
||||
if a.question.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
acache[a.question_id] = set(o.pk for o in a.options.all())
|
||||
elif a.question.type in Question.UNLOCALIZED_TYPES:
|
||||
acache[a.question_id] = a.answer
|
||||
else:
|
||||
acache[a.question_id] = str(a)
|
||||
for q in questions:
|
||||
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
for o in options[q.pk]:
|
||||
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
|
||||
else:
|
||||
row.append(acache.get(q.pk, ''))
|
||||
|
||||
try:
|
||||
row += [
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
order.invoice_address.name_parts.get(k, '')
|
||||
)
|
||||
row += [
|
||||
order.invoice_address.street,
|
||||
order.invoice_address.zipcode,
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.state,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
|
||||
row += [
|
||||
order.sales_channel,
|
||||
order.locale
|
||||
]
|
||||
row.append(', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
]))
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_orders'.format(self.event.slug)
|
||||
if self.is_multievent:
|
||||
return '{}_orders'.format(self.events.first().organizer.slug)
|
||||
else:
|
||||
return '{}_orders'.format(self.event.slug)
|
||||
|
||||
|
||||
class PaymentListExporter(ListExporter):
|
||||
@@ -432,47 +534,49 @@ class PaymentListExporter(ListExporter):
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('successful_only',
|
||||
forms.BooleanField(
|
||||
label=_('Only successful payments'),
|
||||
initial=True,
|
||||
('payment_states',
|
||||
forms.MultipleChoiceField(
|
||||
label=_('Payment states'),
|
||||
choices=OrderPayment.PAYMENT_STATES,
|
||||
initial=[OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED],
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
)),
|
||||
('refund_states',
|
||||
forms.MultipleChoiceField(
|
||||
label=_('Refund states'),
|
||||
choices=OrderRefund.REFUND_STATES,
|
||||
initial=[OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_CREATED,
|
||||
OrderRefund.REFUND_STATE_TRANSIT],
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
provider_names = {
|
||||
k: v.verbose_name
|
||||
for k, v in self.event.get_payment_providers().items()
|
||||
}
|
||||
provider_names = dict(get_all_payment_providers())
|
||||
|
||||
payments = OrderPayment.objects.filter(
|
||||
order__event=self.event,
|
||||
order__event__in=self.events,
|
||||
state__in=form_data.get('payment_states', [])
|
||||
).order_by('created')
|
||||
refunds = OrderRefund.objects.filter(
|
||||
order__event=self.event
|
||||
order__event__in=self.events,
|
||||
state__in=form_data.get('refund_states', [])
|
||||
).order_by('created')
|
||||
|
||||
if form_data['successful_only']:
|
||||
payments = payments.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
)
|
||||
refunds = refunds.filter(
|
||||
state=OrderRefund.REFUND_STATE_DONE,
|
||||
)
|
||||
|
||||
objs = sorted(list(payments) + list(refunds), key=lambda o: o.created)
|
||||
|
||||
headers = [
|
||||
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||
_('Status code'), _('Amount'), _('Payment method')
|
||||
]
|
||||
yield headers
|
||||
|
||||
yield self.ProgressSetTotal(total=len(objs))
|
||||
for obj in objs:
|
||||
tz = pytz.timezone(obj.order.event.settings.timezone)
|
||||
if isinstance(obj, OrderPayment) and obj.payment_date:
|
||||
d2 = obj.payment_date.astimezone(tz).date().strftime('%Y-%m-%d')
|
||||
elif isinstance(obj, OrderRefund) and obj.execution_date:
|
||||
@@ -480,6 +584,7 @@ class PaymentListExporter(ListExporter):
|
||||
else:
|
||||
d2 = ''
|
||||
row = [
|
||||
obj.order.event.slug,
|
||||
obj.order.code,
|
||||
obj.full_id,
|
||||
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
@@ -492,7 +597,10 @@ class PaymentListExporter(ListExporter):
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_payments'.format(self.event.slug)
|
||||
if self.is_multievent:
|
||||
return '{}_payments'.format(self.events.first().organizer.slug)
|
||||
else:
|
||||
return '{}_payments'.format(self.event.slug)
|
||||
|
||||
|
||||
class QuotaListExporter(ListExporter):
|
||||
@@ -502,20 +610,26 @@ class QuotaListExporter(ListExporter):
|
||||
def iterate_list(self, form_data):
|
||||
headers = [
|
||||
_('Quota name'), _('Total quota'), _('Paid orders'), _('Pending orders'), _('Blocking vouchers'),
|
||||
_('Current user\'s carts'), _('Waiting list'), _('Current availability')
|
||||
_('Current user\'s carts'), _('Waiting list'), _('Exited orders'), _('Current availability')
|
||||
]
|
||||
yield headers
|
||||
|
||||
for quota in self.event.quotas.all():
|
||||
avail = quota.availability()
|
||||
quotas = list(self.event.quotas.all())
|
||||
qa = QuotaAvailability(full_results=True)
|
||||
qa.queue(*quotas)
|
||||
qa.compute()
|
||||
|
||||
for quota in quotas:
|
||||
avail = qa.results[quota]
|
||||
row = [
|
||||
quota.name,
|
||||
_('Infinite') if quota.size is None else quota.size,
|
||||
quota.count_paid_orders(),
|
||||
quota.count_pending_orders(),
|
||||
quota.count_blocking_vouchers(),
|
||||
quota.count_in_cart(),
|
||||
quota.count_waiting_list_pending(),
|
||||
qa.count_paid_orders[quota],
|
||||
qa.count_pending_orders[quota],
|
||||
qa.count_vouchers[quota],
|
||||
qa.count_cart[quota],
|
||||
qa.count_waitinglist[quota],
|
||||
qa.count_exited_orders[quota],
|
||||
_('Infinite') if avail[1] is None else avail[1]
|
||||
]
|
||||
yield row
|
||||
@@ -524,218 +638,32 @@ class QuotaListExporter(ListExporter):
|
||||
return '{}_quotas'.format(self.event.slug)
|
||||
|
||||
|
||||
class InvoiceDataExporter(MultiSheetListExporter):
|
||||
identifier = 'invoicedata'
|
||||
verbose_name = gettext_lazy('Invoice data')
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
return (
|
||||
('invoices', _('Invoices')),
|
||||
('lines', _('Invoice lines')),
|
||||
)
|
||||
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
if sheet == 'invoices':
|
||||
yield [
|
||||
_('Invoice number'),
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
_('E-mail address'),
|
||||
_('Invoice type'),
|
||||
_('Cancellation of'),
|
||||
_('Language'),
|
||||
_('Invoice sender:') + ' ' + _('Name'),
|
||||
_('Invoice sender:') + ' ' + _('Address'),
|
||||
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||
_('Invoice sender:') + ' ' + _('City'),
|
||||
_('Invoice sender:') + ' ' + _('Country'),
|
||||
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Company'),
|
||||
_('Invoice recipient:') + ' ' + _('Name'),
|
||||
_('Invoice recipient:') + ' ' + _('Street address'),
|
||||
_('Invoice recipient:') + ' ' + _('ZIP code'),
|
||||
_('Invoice recipient:') + ' ' + _('City'),
|
||||
_('Invoice recipient:') + ' ' + _('Country'),
|
||||
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
|
||||
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||
_('Reverse charge'),
|
||||
_('Shown foreign currency'),
|
||||
_('Foreign currency rate'),
|
||||
_('Total value (with taxes)'),
|
||||
_('Total value (without taxes)'),
|
||||
_('Payment matching IDs'),
|
||||
]
|
||||
qs = self.event.invoices.order_by('full_invoice_no').select_related(
|
||||
'order', 'refers'
|
||||
).prefetch_related('order__payments').annotate(
|
||||
total_gross=Subquery(
|
||||
InvoiceLine.objects.filter(
|
||||
invoice=OuterRef('pk')
|
||||
).order_by().values('invoice').annotate(
|
||||
s=Sum('gross_value')
|
||||
).values('s')
|
||||
),
|
||||
total_net=Subquery(
|
||||
InvoiceLine.objects.filter(
|
||||
invoice=OuterRef('pk')
|
||||
).order_by().values('invoice').annotate(
|
||||
s=Sum(F('gross_value') - F('tax_value'))
|
||||
).values('s')
|
||||
)
|
||||
)
|
||||
for i in qs:
|
||||
pmis = []
|
||||
for p in i.order.payments.all():
|
||||
if p.state in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_CREATED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_REFUNDED):
|
||||
pprov = p.payment_provider
|
||||
if pprov:
|
||||
mid = pprov.matching_id(p)
|
||||
if mid:
|
||||
pmis.append(mid)
|
||||
pmi = '\n'.join(pmis)
|
||||
yield [
|
||||
i.full_invoice_no,
|
||||
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||
i.order.code,
|
||||
i.order.email,
|
||||
_('Cancellation') if i.is_cancellation else _('Invoice'),
|
||||
i.refers.full_invoice_no if i.refers else '',
|
||||
i.locale,
|
||||
i.invoice_from_name,
|
||||
i.invoice_from,
|
||||
i.invoice_from_zipcode,
|
||||
i.invoice_from_city,
|
||||
i.invoice_from_country,
|
||||
i.invoice_from_tax_id,
|
||||
i.invoice_from_vat_id,
|
||||
i.invoice_to_company,
|
||||
i.invoice_to_name,
|
||||
i.invoice_to_street or i.invoice_to,
|
||||
i.invoice_to_zipcode,
|
||||
i.invoice_to_city,
|
||||
i.invoice_to_country,
|
||||
i.invoice_to_state,
|
||||
i.invoice_to_vat_id,
|
||||
i.invoice_to_beneficiary,
|
||||
i.internal_reference,
|
||||
_('Yes') if i.reverse_charge else _('No'),
|
||||
i.foreign_currency_display,
|
||||
i.foreign_currency_rate,
|
||||
i.total_gross if i.total_gross else Decimal('0.00'),
|
||||
Decimal(i.total_net if i.total_net else '0.00').quantize(Decimal('0.01')),
|
||||
pmi
|
||||
]
|
||||
elif sheet == 'lines':
|
||||
yield [
|
||||
_('Invoice number'),
|
||||
_('Line number'),
|
||||
_('Description'),
|
||||
_('Gross price'),
|
||||
_('Net price'),
|
||||
_('Tax value'),
|
||||
_('Tax rate'),
|
||||
_('Tax name'),
|
||||
_('Event start date'),
|
||||
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
_('E-mail address'),
|
||||
_('Invoice type'),
|
||||
_('Cancellation of'),
|
||||
_('Invoice sender:') + ' ' + _('Name'),
|
||||
_('Invoice sender:') + ' ' + _('Address'),
|
||||
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||
_('Invoice sender:') + ' ' + _('City'),
|
||||
_('Invoice sender:') + ' ' + _('Country'),
|
||||
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Company'),
|
||||
_('Invoice recipient:') + ' ' + _('Name'),
|
||||
_('Invoice recipient:') + ' ' + _('Street address'),
|
||||
_('Invoice recipient:') + ' ' + _('ZIP code'),
|
||||
_('Invoice recipient:') + ' ' + _('City'),
|
||||
_('Invoice recipient:') + ' ' + _('Country'),
|
||||
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
|
||||
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||
]
|
||||
qs = InvoiceLine.objects.filter(
|
||||
invoice__event=self.event
|
||||
).order_by('invoice__full_invoice_no', 'position').select_related(
|
||||
'invoice', 'invoice__order', 'invoice__refers'
|
||||
)
|
||||
for l in qs:
|
||||
i = l.invoice
|
||||
yield [
|
||||
i.full_invoice_no,
|
||||
l.position + 1,
|
||||
l.description.replace("<br />", " - "),
|
||||
l.gross_value,
|
||||
l.net_value,
|
||||
l.tax_value,
|
||||
l.tax_rate,
|
||||
l.tax_name,
|
||||
date_format(l.event_date_from, "SHORT_DATE_FORMAT") if l.event_date_from else "",
|
||||
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||
i.order.code,
|
||||
i.order.email,
|
||||
_('Cancellation') if i.is_cancellation else _('Invoice'),
|
||||
i.refers.full_invoice_no if i.refers else '',
|
||||
i.invoice_from_name,
|
||||
i.invoice_from,
|
||||
i.invoice_from_zipcode,
|
||||
i.invoice_from_city,
|
||||
i.invoice_from_country,
|
||||
i.invoice_from_tax_id,
|
||||
i.invoice_from_vat_id,
|
||||
i.invoice_to_company,
|
||||
i.invoice_to_name,
|
||||
i.invoice_to_street or i.invoice_to,
|
||||
i.invoice_to_zipcode,
|
||||
i.invoice_to_city,
|
||||
i.invoice_to_country,
|
||||
i.invoice_to_state,
|
||||
i.invoice_to_vat_id,
|
||||
i.invoice_to_beneficiary,
|
||||
i.internal_reference,
|
||||
]
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_invoices'.format(self.event.slug)
|
||||
|
||||
|
||||
class GiftcardRedemptionListExporter(ListExporter):
|
||||
identifier = 'giftcardredemptionlist'
|
||||
verbose_name = gettext_lazy('Gift card redemptions')
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
payments = OrderPayment.objects.filter(
|
||||
order__event=self.event,
|
||||
order__event__in=self.events,
|
||||
provider='giftcard'
|
||||
).order_by('created')
|
||||
refunds = OrderRefund.objects.filter(
|
||||
order__event=self.event,
|
||||
order__event__in=self.events,
|
||||
provider='giftcard'
|
||||
).order_by('created')
|
||||
|
||||
objs = sorted(list(payments) + list(refunds), key=lambda o: (o.order.code, o.created))
|
||||
|
||||
headers = [
|
||||
_('Order'), _('Payment ID'), _('Date'), _('Gift card code'), _('Amount'), _('Issuer')
|
||||
_('Event slug'), _('Order'), _('Payment ID'), _('Date'), _('Gift card code'), _('Amount'), _('Issuer')
|
||||
]
|
||||
yield headers
|
||||
|
||||
for obj in objs:
|
||||
tz = pytz.timezone(obj.order.event.settings.timezone)
|
||||
gc = GiftCard.objects.get(pk=obj.info_data.get('gift_card'))
|
||||
row = [
|
||||
obj.order.event.slug,
|
||||
obj.order.code,
|
||||
obj.full_id,
|
||||
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
@@ -746,7 +674,10 @@ class GiftcardRedemptionListExporter(ListExporter):
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_giftcardredemptions'.format(self.event.slug)
|
||||
if self.is_multievent:
|
||||
return '{}_giftcardredemptions'.format(self.events.first().organizer.slug)
|
||||
else:
|
||||
return '{}_giftcardredemptions'.format(self.event.slug)
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
|
||||
@@ -754,21 +685,31 @@ def register_orderlist_exporter(sender, **kwargs):
|
||||
return OrderListExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_orderlist")
|
||||
def register_multievent_orderlist_exporter(sender, **kwargs):
|
||||
return OrderListExporter
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_paymentlist")
|
||||
def register_paymentlist_exporter(sender, **kwargs):
|
||||
return PaymentListExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_paymentlist")
|
||||
def register_multievent_paymentlist_exporter(sender, **kwargs):
|
||||
return PaymentListExporter
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_quotalist")
|
||||
def register_quotalist_exporter(sender, **kwargs):
|
||||
return QuotaListExporter
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_invoicedata")
|
||||
def register_invoicedata_exporter(sender, **kwargs):
|
||||
return InvoiceDataExporter
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_giftcardredemptionlist")
|
||||
def register_giftcardredemptionlist_exporter(sender, **kwargs):
|
||||
return GiftcardRedemptionListExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardredemptionlist")
|
||||
def register_multievent_i_giftcardredemptionlist_exporter(sender, **kwargs):
|
||||
return GiftcardRedemptionListExporter
|
||||
|
||||
@@ -48,6 +48,9 @@ class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
SECRET_REDACTED = '*****'
|
||||
|
||||
|
||||
class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
auto_fields = []
|
||||
|
||||
@@ -62,6 +65,8 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
for fname in self.auto_fields:
|
||||
kwargs = DEFAULTS[fname].get('form_kwargs', {})
|
||||
if callable(kwargs):
|
||||
kwargs = kwargs()
|
||||
kwargs.setdefault('required', False)
|
||||
field = DEFAULTS[fname]['form_class'](
|
||||
**kwargs
|
||||
@@ -73,6 +78,12 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
|
||||
f.set_event(self.obj)
|
||||
|
||||
def save(self):
|
||||
for k, v in self.cleaned_data.items():
|
||||
if isinstance(self.fields.get(k), SecretKeySettingsField) and self.cleaned_data.get(k) == SECRET_REDACTED:
|
||||
self.cleaned_data[k] = self.initial[k]
|
||||
return super().save()
|
||||
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
from pretix.base.models import Event
|
||||
|
||||
@@ -111,3 +122,32 @@ class SafeSessionWizardView(SessionWizardView):
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class SecretKeySettingsWidget(forms.TextInput):
|
||||
def __init__(self, attrs=None):
|
||||
if attrs is None:
|
||||
attrs = {}
|
||||
attrs.update({
|
||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||
})
|
||||
super().__init__(attrs)
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
if value:
|
||||
value = SECRET_REDACTED
|
||||
return super().get_context(name, value, attrs)
|
||||
|
||||
|
||||
class SecretKeySettingsField(forms.CharField):
|
||||
widget = SecretKeySettingsWidget
|
||||
|
||||
def has_changed(self, initial, data):
|
||||
if data == SECRET_REDACTED:
|
||||
return False
|
||||
return super().has_changed(initial, data)
|
||||
|
||||
def run_validators(self, value):
|
||||
if value == SECRET_REDACTED:
|
||||
return
|
||||
return super().run_validators(value)
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import hashlib
|
||||
import ipaddress
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
)
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import User
|
||||
from pretix.helpers.dicts import move_to_end
|
||||
from pretix.helpers.http import get_client_ip
|
||||
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
@@ -18,6 +23,7 @@ class LoginForm(forms.Form):
|
||||
|
||||
error_messages = {
|
||||
'invalid_login': _("This combination of credentials is not known to our system."),
|
||||
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||
'inactive': _("This account is inactive.")
|
||||
}
|
||||
|
||||
@@ -39,10 +45,36 @@ class LoginForm(forms.Form):
|
||||
else:
|
||||
move_to_end(self.fields, 'keep_logged_in')
|
||||
|
||||
@cached_property
|
||||
def ratelimit_key(self):
|
||||
if not settings.HAS_REDIS:
|
||||
return None
|
||||
client_ip = get_client_ip(self.request)
|
||||
if not client_ip:
|
||||
return None
|
||||
try:
|
||||
client_ip = ipaddress.ip_address(client_ip)
|
||||
except ValueError:
|
||||
# Web server not set up correctly
|
||||
return None
|
||||
if client_ip.is_private:
|
||||
# This is the private IP of the server, web server not set up correctly
|
||||
return None
|
||||
return 'pretix_login_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
|
||||
|
||||
def clean(self):
|
||||
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
|
||||
if self.ratelimit_key:
|
||||
from django_redis import get_redis_connection
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.get(self.ratelimit_key)
|
||||
if cnt and int(cnt) > 10:
|
||||
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
|
||||
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
|
||||
if self.user_cache is None:
|
||||
if self.ratelimit_key:
|
||||
rc.incr(self.ratelimit_key)
|
||||
rc.expire(self.ratelimit_key, 300)
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['invalid_login'],
|
||||
code='invalid_login'
|
||||
|
||||
@@ -40,7 +40,7 @@ from pretix.base.settings import (
|
||||
PERSON_NAME_TITLE_GROUPS,
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.control.forms import SplitDateTimeField
|
||||
from pretix.control.forms import ExtFileField, SplitDateTimeField
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.escapejson import escapejson_attr
|
||||
from pretix.helpers.i18n import get_format_without_seconds
|
||||
@@ -184,6 +184,10 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
raise forms.ValidationError(self.error_messages['required'], code='required')
|
||||
if self.require_all_fields and not all(v for v in value):
|
||||
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
|
||||
|
||||
if sum(len(v) for v in value if v) > 250:
|
||||
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@@ -205,12 +209,13 @@ def guess_country(event):
|
||||
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 in valid_countries:
|
||||
if locale.upper() in valid_countries:
|
||||
country = Country(locale.upper())
|
||||
return country
|
||||
|
||||
@@ -243,8 +248,10 @@ class BaseQuestionsForm(forms.Form):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
add_fields = {}
|
||||
|
||||
if item.admission and event.settings.attendee_names_asked:
|
||||
self.fields['attendee_name_parts'] = NamePartsFormField(
|
||||
add_fields['attendee_name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.attendee_names_required and not self.all_optional,
|
||||
scheme=event.settings.name_scheme,
|
||||
@@ -253,7 +260,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
|
||||
)
|
||||
if item.admission and event.settings.attendee_emails_asked:
|
||||
self.fields['attendee_email'] = forms.EmailField(
|
||||
add_fields['attendee_email'] = forms.EmailField(
|
||||
required=event.settings.attendee_emails_required and not self.all_optional,
|
||||
label=_('Attendee email'),
|
||||
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
|
||||
@@ -264,14 +271,15 @@ class BaseQuestionsForm(forms.Form):
|
||||
)
|
||||
)
|
||||
if item.admission and event.settings.attendee_company_asked:
|
||||
self.fields['company'] = forms.CharField(
|
||||
add_fields['company'] = forms.CharField(
|
||||
required=event.settings.attendee_company_required and not self.all_optional,
|
||||
label=_('Company'),
|
||||
max_length=255,
|
||||
initial=(cartpos.company if cartpos else orderpos.company),
|
||||
)
|
||||
|
||||
if item.admission and event.settings.attendee_addresses_asked:
|
||||
self.fields['street'] = forms.CharField(
|
||||
add_fields['street'] = forms.CharField(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('Address'),
|
||||
widget=forms.Textarea(attrs={
|
||||
@@ -281,24 +289,26 @@ class BaseQuestionsForm(forms.Form):
|
||||
}),
|
||||
initial=(cartpos.street if cartpos else orderpos.street),
|
||||
)
|
||||
self.fields['zipcode'] = forms.CharField(
|
||||
add_fields['zipcode'] = forms.CharField(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
max_length=30,
|
||||
label=_('ZIP code'),
|
||||
initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'postal-code',
|
||||
}),
|
||||
)
|
||||
self.fields['city'] = forms.CharField(
|
||||
add_fields['city'] = forms.CharField(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('City'),
|
||||
max_length=255,
|
||||
initial=(cartpos.city if cartpos else orderpos.city),
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'address-level2',
|
||||
}),
|
||||
)
|
||||
country = (cartpos.country if cartpos else orderpos.country) or guess_country(event)
|
||||
self.fields['country'] = CountryField(
|
||||
add_fields['country'] = CountryField(
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
@@ -323,7 +333,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'state']
|
||||
|
||||
self.fields['state'] = forms.ChoiceField(
|
||||
add_fields['state'] = forms.ChoiceField(
|
||||
label=pgettext_lazy('address', 'State'),
|
||||
required=False,
|
||||
choices=c,
|
||||
@@ -331,7 +341,14 @@ class BaseQuestionsForm(forms.Form):
|
||||
'autocomplete': 'address-level1',
|
||||
}),
|
||||
)
|
||||
self.fields['state'].widget.is_required = True
|
||||
add_fields['state'].widget.is_required = True
|
||||
|
||||
field_positions = list(
|
||||
[
|
||||
(n, event.settings.system_question_order.get(n if n != 'state' else 'country', 0))
|
||||
for n in add_fields.keys()
|
||||
]
|
||||
)
|
||||
|
||||
for q in questions:
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
@@ -384,13 +401,14 @@ class BaseQuestionsForm(forms.Form):
|
||||
)
|
||||
elif q.type == Question.TYPE_COUNTRYCODE:
|
||||
field = CountryField(
|
||||
countries=CachedCountries
|
||||
countries=CachedCountries,
|
||||
blank=True, null=True, blank_label=' ',
|
||||
).formfield(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
empty_label='',
|
||||
initial=initial.answer if initial else guess_country(event),
|
||||
empty_label=' ',
|
||||
initial=initial.answer if initial else (guess_country(event) if required else None),
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
@@ -412,11 +430,17 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial=initial.options.all() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_FILE:
|
||||
field = forms.FileField(
|
||||
field = ExtFileField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
ext_whitelist=(
|
||||
".png", ".jpg", ".gif", ".jpeg", ".pdf", ".txt", ".docx", ".gif", ".svg",
|
||||
".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages",
|
||||
".bmp", ".tif", ".tiff"
|
||||
),
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
field = forms.DateField(
|
||||
@@ -478,7 +502,12 @@ class BaseQuestionsForm(forms.Form):
|
||||
field._required = q.required and not self.all_optional
|
||||
field.required = False
|
||||
|
||||
self.fields['question_%s' % q.id] = field
|
||||
add_fields['question_%s' % q.id] = field
|
||||
field_positions.append(('question_%s' % q.id, q.position))
|
||||
|
||||
field_positions.sort(key=lambda e: e[1])
|
||||
for fname, p in field_positions:
|
||||
self.fields[fname] = add_fields[fname]
|
||||
|
||||
responses = question_form_fields.send(sender=event, position=pos)
|
||||
data = pos.meta_info_data
|
||||
@@ -525,7 +554,8 @@ class BaseQuestionsForm(forms.Form):
|
||||
|
||||
if not self.all_optional:
|
||||
for q in question_cache.values():
|
||||
if question_is_required(q) and not d.get('question_%d' % q.pk):
|
||||
answer = d.get('question_%d' % q.pk)
|
||||
if question_is_required(q) and not answer and answer != 0:
|
||||
raise ValidationError({'question_%d' % q.pk: [_('This field is required')]})
|
||||
|
||||
return d
|
||||
@@ -559,7 +589,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
'data-display-dependency': '#id_is_business_1',
|
||||
'autocomplete': 'organization',
|
||||
}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-in-eu': ','.join(EU_COUNTRIES)}),
|
||||
'internal_reference': forms.TextInput,
|
||||
}
|
||||
labels = {
|
||||
@@ -609,6 +639,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
)
|
||||
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.
|
||||
if cc and cc not in EU_COUNTRIES and fprefix + 'vat_id' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'vat_id']
|
||||
|
||||
if not event.settings.invoice_address_required or self.all_optional:
|
||||
for k, f in self.fields.items():
|
||||
f.required = False
|
||||
@@ -623,8 +658,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.fields['company'].widget.is_required = True
|
||||
self.fields['company'].widget.attrs['required'] = 'required'
|
||||
del self.fields['company'].widget.attrs['data-display-dependency']
|
||||
if 'vat_id' in self.fields:
|
||||
del self.fields['vat_id'].widget.attrs['data-display-dependency']
|
||||
|
||||
self.fields['name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
@@ -656,6 +689,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
data = self.cleaned_data
|
||||
if not data.get('is_business'):
|
||||
data['company'] = ''
|
||||
data['vat_id'] = ''
|
||||
if data.get('is_business') and not data.get('country') in EU_COUNTRIES:
|
||||
data['vat_id'] = ''
|
||||
if self.event.settings.invoice_address_required:
|
||||
if data.get('is_business') and not data.get('company'):
|
||||
raise ValidationError(_('You need to provide a company name.'))
|
||||
@@ -676,7 +712,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
) and len(data.get('name_parts', {})) == 1:
|
||||
# Do not save the country if it is the only field set -- we don't know the user even checked it!
|
||||
self.cleaned_data['country'] = ''
|
||||
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass
|
||||
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
@@ -19,6 +20,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
"address or password."),
|
||||
'pw_current_wrong': _("The current password you entered was not correct."),
|
||||
'pw_mismatch': _("Please enter the same password twice"),
|
||||
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||
}
|
||||
|
||||
old_pw = forms.CharField(max_length=255,
|
||||
@@ -64,6 +66,18 @@ class UserSettingsForm(forms.ModelForm):
|
||||
|
||||
def clean_old_pw(self):
|
||||
old_pw = self.cleaned_data.get('old_pw')
|
||||
|
||||
if old_pw and settings.HAS_REDIS:
|
||||
from django_redis import get_redis_connection
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
|
||||
rc.expire('pretix_pwchange_%s' % self.user.pk, 300)
|
||||
if cnt > 10:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
|
||||
if old_pw and not check_password(old_pw, self.user.password):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_current_wrong'],
|
||||
|
||||
@@ -15,6 +15,7 @@ class DatePickerWidget(forms.DateInput):
|
||||
date_attrs = dict(attrs)
|
||||
date_attrs.setdefault('class', 'form-control')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
|
||||
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
date_attrs['placeholder'] = now().replace(
|
||||
@@ -32,6 +33,7 @@ class TimePickerWidget(forms.TimeInput):
|
||||
time_attrs = dict(attrs)
|
||||
time_attrs.setdefault('class', 'form-control')
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
|
||||
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
time_attrs['placeholder'] = now().replace(
|
||||
@@ -102,6 +104,8 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
time_attrs.setdefault('autocomplete', 'off')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
|
||||
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
|
||||
|
||||
def date_placeholder():
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
|
||||
@@ -4,6 +4,9 @@ from django.conf import settings
|
||||
from django.utils import translation
|
||||
from django.utils.formats import date_format, number_format
|
||||
from django.utils.translation import gettext
|
||||
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
|
||||
from i18nfield.fields import ( # noqa
|
||||
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
|
||||
)
|
||||
@@ -12,8 +15,6 @@ from i18nfield.forms import I18nFormField # noqa
|
||||
from i18nfield.strings import LazyI18nString # noqa
|
||||
from i18nfield.utils import I18nJSONEncoder # noqa
|
||||
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
|
||||
|
||||
class LazyDate:
|
||||
def __init__(self, value):
|
||||
@@ -26,6 +27,21 @@ class LazyDate:
|
||||
return date_format(self.value, "SHORT_DATE_FORMAT")
|
||||
|
||||
|
||||
class LazyExpiresDate:
|
||||
def __init__(self, expires):
|
||||
self.value = expires
|
||||
|
||||
def __format__(self, format_spec):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
at_end_of_day = self.value.hour == 23 and self.value.minute == 59 and self.value.second >= 59
|
||||
if at_end_of_day:
|
||||
return date_format(self.value, "SHORT_DATE_FORMAT")
|
||||
else:
|
||||
return date_format(self.value, "SHORT_DATETIME_FORMAT")
|
||||
|
||||
|
||||
class LazyCurrencyNumber:
|
||||
def __init__(self, value, currency):
|
||||
self.value = value
|
||||
|
||||
@@ -391,7 +391,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
p_size = p.wrap(self.event_width, self.event_height)
|
||||
return txt
|
||||
|
||||
if not self.invoice.event.has_subevents:
|
||||
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:
|
||||
p_str = (
|
||||
shorten(self.invoice.event.name) + '\n' +
|
||||
@@ -672,6 +672,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
table
|
||||
]))
|
||||
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
|
||||
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
|
||||
story.append(Spacer(1, 5 * mm))
|
||||
story.append(Paragraph(
|
||||
pgettext(
|
||||
@@ -679,7 +680,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
'{date}, the invoice total corresponds to {total}.'
|
||||
).format(rate=localize(self.invoice.foreign_currency_rate),
|
||||
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"),
|
||||
total=fmt(total)),
|
||||
total=fmt(foreign_total)),
|
||||
self.stylesheet['Fineprint']
|
||||
))
|
||||
|
||||
|
||||
@@ -1,46 +1,91 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytz
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.timezone import override
|
||||
from django_scopes import scope
|
||||
from tqdm import tqdm
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Event, Organizer
|
||||
from pretix.base.signals import register_data_exporters
|
||||
from pretix.base.signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run an exporter to get data out of pretix"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('organizer_slug', nargs=1, type=str)
|
||||
parser.add_argument('event_slug', nargs=1, type=str)
|
||||
parser.add_argument('export_provider', nargs=1, type=str)
|
||||
parser.add_argument('output_file', nargs=1, type=str)
|
||||
parser.add_argument('organizer_slug', type=str)
|
||||
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument('event_slug', nargs="?", type=str)
|
||||
group.add_argument('--all-events', action='store_true')
|
||||
group.add_argument('--event_slugs', nargs="+", type=str)
|
||||
|
||||
parser.add_argument('export_provider', type=str)
|
||||
parser.add_argument('output_file', type=str)
|
||||
parser.add_argument('--parameters', action='store', type=str, help='JSON-formatted parameters')
|
||||
parser.add_argument('--locale', action='store', type=str, help='...')
|
||||
parser.add_argument('--timezone', action='store', type=str, help='...')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
o = Organizer.objects.get(slug=options['organizer_slug'][0])
|
||||
o = Organizer.objects.get(slug=options['organizer_slug'])
|
||||
except Organizer.DoesNotExist:
|
||||
self.stderr.write(self.style.ERROR('Organizer not found.'))
|
||||
sys.exit(1)
|
||||
|
||||
with scope(organizer=o):
|
||||
try:
|
||||
e = o.events.get(slug=options['event_slug'][0])
|
||||
except Event.DoesNotExist:
|
||||
self.stderr.write(self.style.ERROR('Event not found.'))
|
||||
sys.exit(1)
|
||||
locale = options.get("locale", None)
|
||||
timezone = pytz.timezone(options['timezone']) if options.get('timezone') else None
|
||||
|
||||
with language(e.settings.locale), override(e.settings.timezone):
|
||||
responses = register_data_exporters.send(e)
|
||||
for receiver, response in responses:
|
||||
ex = response(e)
|
||||
if ex.identifier == options['export_provider'][0]:
|
||||
with scope(organizer=o):
|
||||
if options['event_slug']:
|
||||
try:
|
||||
e = o.events.get(slug=options['event_slug'])
|
||||
except Event.DoesNotExist:
|
||||
self.stderr.write(self.style.ERROR('Event not found.'))
|
||||
sys.exit(1)
|
||||
if not locale:
|
||||
locale = e.settings.locale
|
||||
if not timezone:
|
||||
timezone = e.settings.timezone
|
||||
signal_result = register_data_exporters.send(e)
|
||||
else:
|
||||
e = o.events.all()
|
||||
if options['event_slugs']:
|
||||
e = e.filter(slug__in=options['event_slugs'])
|
||||
not_found = set(options['event_slugs']).difference(event.slug for event in e)
|
||||
if not_found:
|
||||
self.stderr.write(self.style.ERROR('The following events were not found: {}'.format(", ".join(not_found))))
|
||||
sys.exit(1)
|
||||
if not e.exists():
|
||||
self.stderr.write(self.style.ERROR('No events found.'))
|
||||
sys.exit(1)
|
||||
|
||||
if not locale:
|
||||
locale = e.first().settings.locale
|
||||
self.stderr.write(self.style.WARNING(
|
||||
"Guessing locale '{}' based on event '{}'.".format(locale, e.first().slug)))
|
||||
if not timezone:
|
||||
timezone = e.first().settings.timezone
|
||||
self.stderr.write(self.style.WARNING(
|
||||
"Guessing timezone '{}' based on event '{}'.".format(timezone, e.first().slug)))
|
||||
signal_result = register_multievent_data_exporters.send(o)
|
||||
|
||||
pbar = tqdm(total=100)
|
||||
|
||||
def report_status(val):
|
||||
pbar.update(round(val, 2) - pbar.n)
|
||||
|
||||
with language(locale), override(timezone):
|
||||
for receiver, response in signal_result:
|
||||
ex = response(e, report_status)
|
||||
if ex.identifier == options['export_provider']:
|
||||
params = json.loads(options.get('parameters') or '{}')
|
||||
with open(options['output_file'][0], 'wb') as f:
|
||||
with open(options['output_file'], 'wb') as f:
|
||||
try:
|
||||
ex.render(form_data=params, output_file=f)
|
||||
except TypeError:
|
||||
@@ -53,6 +98,7 @@ class Command(BaseCommand):
|
||||
f.write(d[2])
|
||||
|
||||
sys.exit(0)
|
||||
pbar.close()
|
||||
|
||||
self.stderr.write(self.style.ERROR('Export provider not found.'))
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.dispatch.dispatcher import NO_RECEIVERS
|
||||
|
||||
from pretix.helpers.periodic import SKIPPED
|
||||
|
||||
from ...signals import periodic_task
|
||||
|
||||
@@ -9,12 +14,30 @@ class Command(BaseCommand):
|
||||
help = "Run periodic tasks"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
for recv, resp in periodic_task.send_robust(self):
|
||||
if isinstance(resp, Exception):
|
||||
verbosity = int(options['verbosity'])
|
||||
|
||||
if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS:
|
||||
return
|
||||
|
||||
for receiver in periodic_task._live_receivers(self):
|
||||
if verbosity > 1:
|
||||
self.stdout.write(f'Running {receiver.__module__}.{receiver.__name__}…')
|
||||
t0 = time.time()
|
||||
try:
|
||||
r = receiver(signal=periodic_task, sender=self)
|
||||
except Exception as err:
|
||||
if isinstance(Exception, KeyboardInterrupt):
|
||||
raise err
|
||||
if settings.SENTRY_ENABLED:
|
||||
from sentry_sdk import capture_exception
|
||||
capture_exception(resp)
|
||||
capture_exception(err)
|
||||
self.stdout.write(self.style.ERROR(f'FAIL: {str(err)}\n'))
|
||||
else:
|
||||
raise resp
|
||||
|
||||
call_command('clearsessions')
|
||||
self.stdout.write(self.style.ERROR(f'FAIL: {str(err)}\n'))
|
||||
traceback.print_exc()
|
||||
else:
|
||||
if options.get('verbosity') > 1:
|
||||
if r is SKIPPED:
|
||||
self.stdout.write(self.style.SUCCESS(f'Skipped {receiver.__module__}.{receiver.__name__}'))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f'Completed {receiver.__module__}.{receiver.__name__} in {round(time.time() - t0, 3)}s'))
|
||||
|
||||
@@ -18,6 +18,7 @@ class Command(BaseCommand):
|
||||
cmd = 'shell_plus'
|
||||
except ImportError:
|
||||
cmd = 'shell'
|
||||
del options['skip_checks']
|
||||
|
||||
parser = self.create_parser(sys.argv[0], sys.argv[1])
|
||||
flags = parser.parse_known_args(sys.argv[2:])[1]
|
||||
|
||||
@@ -3,6 +3,9 @@ from collections import defaultdict
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
|
||||
from pretix.base.models import Event, Invoice, Order, OrderPosition, Organizer
|
||||
|
||||
if settings.HAS_REDIS:
|
||||
import django_redis
|
||||
@@ -201,6 +204,19 @@ class Histogram(Metric):
|
||||
self._execute_redis_pipeline(pipe)
|
||||
|
||||
|
||||
def estimate_count_fast(type):
|
||||
"""
|
||||
See https://wiki.postgresql.org/wiki/Count_estimate
|
||||
"""
|
||||
if 'postgres' in settings.DATABASES['default']['ENGINE']:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute("select reltuples from pg_class where relname='%s';" % type._meta.db_table)
|
||||
row = cursor.fetchone()
|
||||
return int(row[0])
|
||||
else:
|
||||
return type.objects.count()
|
||||
|
||||
|
||||
def metric_values():
|
||||
"""
|
||||
Produces the the values to be presented to the monitoring system
|
||||
@@ -223,8 +239,14 @@ def metric_values():
|
||||
metrics[a] = metrics[atarget]
|
||||
|
||||
# Throwaway metrics
|
||||
exact_tables = [
|
||||
Order, OrderPosition, Invoice, Event, Organizer
|
||||
]
|
||||
for m in apps.get_models(): # Count all models
|
||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count()
|
||||
if any(issubclass(m, p) for p in exact_tables):
|
||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count()
|
||||
else:
|
||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = estimate_count_fast(m)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
@@ -199,9 +199,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'object-src': ["'none'"],
|
||||
# frame-src is deprecated but kept for compatibility with CSP 1.0 browsers, e.g. Safari 9
|
||||
'frame-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'child-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}", "https://checkout.stripe.com"],
|
||||
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"] + img_src,
|
||||
@@ -212,8 +210,9 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
# single-sign-on this can be nearly anything so we cannot really restrict
|
||||
# this. However, we'll restrict it to HTTPS.
|
||||
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
|
||||
'report-uri': ["/csp_report/"],
|
||||
}
|
||||
if settings.LOG_CSP:
|
||||
h['report-uri'] = ["/csp_report/"]
|
||||
if 'Content-Security-Policy' in resp:
|
||||
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
|
||||
|
||||
|
||||