forked from CGM_Public/pretix_original
Compare commits
529 Commits
v3.9.0
...
stripe-con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
951e498089 | ||
|
|
b4a7729cb5 | ||
|
|
f2e5e89970 | ||
|
|
4fd773caf6 | ||
|
|
6402f0d86e | ||
|
|
f5d93eaffa | ||
|
|
3f40a8e6fa | ||
|
|
b947467589 | ||
|
|
810f3d7d31 | ||
|
|
e8f3ad633a | ||
|
|
301849f771 | ||
|
|
ee6a595e26 | ||
|
|
980296e38a | ||
|
|
0a62ee0e33 | ||
|
|
afc1013d69 | ||
|
|
16cf3cec76 | ||
|
|
0105b9642d | ||
|
|
3ec15fa529 | ||
|
|
703eebab47 | ||
|
|
3aec3a52fc | ||
|
|
fd93cac8cf | ||
|
|
e139924696 | ||
|
|
da725c0bff | ||
|
|
dca61447cf | ||
|
|
f54bf3f1ea | ||
|
|
3cef9bac26 | ||
|
|
4f20849e4b | ||
|
|
758981fc1b | ||
|
|
9b671d6370 | ||
|
|
3bfaf55094 | ||
|
|
3708dab656 | ||
|
|
14ad7716bd | ||
|
|
985d3c3993 | ||
|
|
fa2222e629 | ||
|
|
13eabdd7f4 | ||
|
|
4fd748e6d9 | ||
|
|
f48ded0165 | ||
|
|
903ea09140 | ||
|
|
fadc610b8e | ||
|
|
ac4b8a392b | ||
|
|
22d986a709 | ||
|
|
bca34145f1 | ||
|
|
97af6f7311 | ||
|
|
67156a67aa | ||
|
|
4ed872d4ef | ||
|
|
5cd6cba0a2 | ||
|
|
72bb5bd177 | ||
|
|
d392e14a96 | ||
|
|
d7459b3b83 | ||
|
|
b4778b5845 | ||
|
|
5a09759cb9 | ||
|
|
2fbaa90d76 | ||
|
|
93f10d33a9 | ||
|
|
e9a972ad60 | ||
|
|
a31f0c1bc8 | ||
|
|
1b0c2f3bb7 | ||
|
|
766428c469 | ||
|
|
d85583f70a | ||
|
|
ee801bd717 | ||
|
|
af0e8ec992 | ||
|
|
bc3325c1cb | ||
|
|
753c331887 | ||
|
|
cfc9055ec1 | ||
|
|
c131a2ac3a | ||
|
|
17fe3355d1 | ||
|
|
0381d42d41 | ||
|
|
b73db911e9 | ||
|
|
1f3d4a2810 | ||
|
|
3cbcf663e5 | ||
|
|
ae0637a3d6 | ||
|
|
a6a9c08a0a | ||
|
|
f3b3d0b8f7 | ||
|
|
9490f20a6c | ||
|
|
4555a917b2 | ||
|
|
951e99d0da | ||
|
|
d0b002cf0c | ||
|
|
4fb0b948ec | ||
|
|
2384478b45 | ||
|
|
f3a2d0cb03 | ||
|
|
1b11d88442 | ||
|
|
954951ddfa | ||
|
|
c01b96bdfc | ||
|
|
c78e88a1ba | ||
|
|
4cb18218b2 | ||
|
|
450d017c32 | ||
|
|
655977e33d | ||
|
|
0cb0620df0 | ||
|
|
c8bf069650 | ||
|
|
e65087fd68 | ||
|
|
d67d389b9d | ||
|
|
0e805e50f9 | ||
|
|
a4d133731e | ||
|
|
c74e7fd4fb | ||
|
|
0e405d2327 | ||
|
|
035c707427 | ||
|
|
787e7ec993 | ||
|
|
09a9b4a456 | ||
|
|
e2547c2761 | ||
|
|
c7b2baf40f | ||
|
|
59595c9db8 | ||
|
|
2f8baecd68 | ||
|
|
a76f74b161 | ||
|
|
f2518101ef | ||
|
|
ec667545e8 | ||
|
|
afb789226c | ||
|
|
bca7a6db93 | ||
|
|
429ad4da37 | ||
|
|
cd6e6004af | ||
|
|
e9d5665a3d | ||
|
|
4cbc30a7ea | ||
|
|
2b0388c2ee | ||
|
|
06b8826e57 | ||
|
|
7c6f0f45a3 | ||
|
|
93399f51b3 | ||
|
|
87bd54b233 | ||
|
|
3fb237f434 | ||
|
|
d7640d25f5 | ||
|
|
1669d3f5c7 | ||
|
|
5aa3f3e772 | ||
|
|
b7a2f0257f | ||
|
|
5c0f29f959 | ||
|
|
59655dca82 | ||
|
|
af2b4ebb4b | ||
|
|
d5b3528f92 | ||
|
|
0a1b41235b | ||
|
|
8ca544064b | ||
|
|
1e2b305376 | ||
|
|
bfa20e995a | ||
|
|
e7fd0f116b | ||
|
|
e836da09cd | ||
|
|
22c6553a48 | ||
|
|
ea5fc3df40 | ||
|
|
7977b6dc15 | ||
|
|
59df5fe052 | ||
|
|
c4e00e7601 | ||
|
|
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 |
2
.github/workflows/docs.yml
vendored
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
|
||||
|
||||
4
.github/workflows/strings.yml
vendored
4
.github/workflows/strings.yml
vendored
@@ -29,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
|
||||
@@ -54,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
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
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -55,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
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
|
||||
|
||||
@@ -95,6 +95,11 @@ Example::
|
||||
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``.
|
||||
|
||||
``loglevel``
|
||||
Set console and file loglevel (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` or ``CRITICAL``). Defaults to ``INFO``.
|
||||
|
||||
Locale settings
|
||||
---------------
|
||||
@@ -337,6 +342,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
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
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Obtaining an authorization grant
|
||||
--------------------------------
|
||||
|
||||
To authorize a new user, link or redirect them to the ``authorize`` endpoint, passing your client ID as a query
|
||||
parameter. Additionally, you can pass a scope (currently either ``read``, ``write``, or ``read write``)
|
||||
parameter. Additionally, you can pass a scope (currently either ``read``, ``write``, ``read write`` or ``profile``)
|
||||
and an URL the user should be redirected to after successful or failed authorization. You also need to pass the
|
||||
``response_type`` parameter with a value of ``code``. Example::
|
||||
|
||||
@@ -47,11 +47,9 @@ You will need this ``code`` parameter to perform the next step.
|
||||
|
||||
On a failed registration, a query string like ``?error=access_denied`` will be appended to the redirection URL.
|
||||
|
||||
.. note:: In this step, the user is allowed to restrict your access to certain organizer accounts. If you try to
|
||||
re-authenticate the user later, the user might be instantly redirected back to you if authorization is already
|
||||
given and would therefore be unable to review their organizer restriction settings. You can append the
|
||||
``approval_prompt=force`` query parameter if you want to make sure the user actively needs to confirm the
|
||||
authorization.
|
||||
.. note:: By default, the user is asked to give permission on every call to this URL. If you **only** request the
|
||||
``profile`` scope, i.e. no access to organizer data, you can pass the ``approval_prompt=auto`` parameter
|
||||
to skip user interaction on subsequen calls.
|
||||
|
||||
Getting an access token
|
||||
-----------------------
|
||||
@@ -193,10 +191,11 @@ If you need the user's meta data, you can fetch it here:
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
email: "admin@localhost",
|
||||
fullname: "John Doe",
|
||||
locale: "de",
|
||||
timezone: "Europe/Berlin"
|
||||
"email": "admin@localhost",
|
||||
"fullname": "John Doe",
|
||||
"locale": "de",
|
||||
"is_staff": false,
|
||||
"timezone": "Europe/Berlin"
|
||||
}
|
||||
|
||||
:statuscode 200: no error
|
||||
|
||||
@@ -56,6 +56,10 @@ rules object Custom check-in
|
||||
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
|
||||
---------
|
||||
|
||||
@@ -109,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
|
||||
@@ -602,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.
|
||||
|
||||
224
doc/api/resources/devices.rst
Normal file
224
doc/api/resources/devices.rst
Normal file
@@ -0,0 +1,224 @@
|
||||
.. spelling:: fullname
|
||||
|
||||
.. _`rest-devices`:
|
||||
|
||||
Devices
|
||||
=======
|
||||
|
||||
See also :ref:`rest-deviceauth`.
|
||||
|
||||
Device resource
|
||||
----------------
|
||||
|
||||
The device resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
device_id integer Internal ID of the device within this organizer
|
||||
unique_serial string Unique identifier of this device
|
||||
name string Device name
|
||||
all_events boolean Whether this device has access to all events
|
||||
limit_events list List of event slugs this device has access to
|
||||
hardware_brand string Device hardware manufacturer (read-only)
|
||||
hardware_model string Device hardware model (read-only)
|
||||
software_brand string Device software product (read-only)
|
||||
software_version string Device software version (read-only)
|
||||
created datetime Creation time
|
||||
initialized datetime Time of initialization (or ``null``)
|
||||
initialization_token string Token for initialization
|
||||
revoked boolean Whether this device no longer has access
|
||||
security_profile string The name of a supported security profile restricting API access
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Device endpoints
|
||||
----------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/devices/
|
||||
|
||||
Returns a list of all devices within a given organizer.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/devices/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"device_id": 1,
|
||||
"unique_serial": "UOS3GNZ27O39V3QS",
|
||||
"initialization_token": "frkso3m2w58zuw70",
|
||||
"all_events": false,
|
||||
"limit_events": [
|
||||
"museum"
|
||||
],
|
||||
"revoked": false,
|
||||
"name": "Scanner",
|
||||
"created": "2020-09-18T14:17:40.971519Z",
|
||||
"initialized": "2020-09-18T14:17:44.190021Z",
|
||||
"security_profile": "full",
|
||||
"hardware_brand": "Zebra",
|
||||
"hardware_model": "TC25",
|
||||
"software_brand": "pretixSCAN",
|
||||
"software_version": "1.5.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/devices/(device_id)/
|
||||
|
||||
Returns information on one device, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/devices/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"device_id": 1,
|
||||
"unique_serial": "UOS3GNZ27O39V3QS",
|
||||
"initialization_token": "frkso3m2w58zuw70",
|
||||
"all_events": false,
|
||||
"limit_events": [
|
||||
"museum"
|
||||
],
|
||||
"revoked": false,
|
||||
"name": "Scanner",
|
||||
"created": "2020-09-18T14:17:40.971519Z",
|
||||
"initialized": "2020-09-18T14:17:44.190021Z",
|
||||
"security_profile": "full",
|
||||
"hardware_brand": "Zebra",
|
||||
"hardware_model": "TC25",
|
||||
"software_brand": "pretixSCAN",
|
||||
"software_version": "1.5.1"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param device_id: The ``device_id`` field of the device to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/devices/
|
||||
|
||||
Creates a new device
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/devices/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Scanner",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"device_id": 1,
|
||||
"unique_serial": "UOS3GNZ27O39V3QS",
|
||||
"initialization_token": "frkso3m2w58zuw70",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"revoked": false,
|
||||
"name": "Scanner",
|
||||
"created": "2020-09-18T14:17:40.971519Z",
|
||||
"security_profile": "full",
|
||||
"initialized": null
|
||||
"hardware_brand": null,
|
||||
"hardware_model": null,
|
||||
"software_brand": null,
|
||||
"software_version": null
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a device for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The device could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/devices/(device_id)/
|
||||
|
||||
Update a device.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/devices/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"name": "Foo"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Foo",
|
||||
...
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param device_id: The ``device_id`` field of the deviec to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The device could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
|
||||
|
||||
@@ -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**:
|
||||
|
||||
|
||||
@@ -209,14 +209,15 @@ Endpoints
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/giftcards/1/transact/ HTTP/1.1
|
||||
POST /api/v1/organizers/bigevents/giftcards/1/transact/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
Content-Length: 79
|
||||
|
||||
{
|
||||
"value": "2.00"
|
||||
"value": "2.00",
|
||||
"text": "Optional value explaining the transaction"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
@@ -24,6 +24,7 @@ Resources and endpoints
|
||||
giftcards
|
||||
carts
|
||||
teams
|
||||
devices
|
||||
webhooks
|
||||
seatingplans
|
||||
billing_invoices
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -193,6 +201,7 @@ addon_to integer Internal ID of
|
||||
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
|
||||
pseudonymization_id string A random ID, e.g. for use in lead scanning apps
|
||||
checkins list of objects List of check-ins with this ticket
|
||||
├ id integer Internal ID of the check-in event
|
||||
├ list integer Internal ID of the check-in list
|
||||
├ datetime datetime Time of check-in
|
||||
├ type string Type of scan (defaults to ``entry``)
|
||||
@@ -468,6 +477,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.
|
||||
@@ -480,6 +490,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
|
||||
@@ -923,7 +935,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
|
||||
@@ -1017,6 +1030,10 @@ Creating orders
|
||||
Order state operations
|
||||
----------------------
|
||||
|
||||
.. versionchanged:: 3.12
|
||||
|
||||
The ``mark_paid`` operation now takes a ``send_email`` parameter.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_paid/
|
||||
|
||||
Marks a pending or expired order as successfully paid.
|
||||
@@ -1028,6 +1045,11 @@ Order state operations
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_paid/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"send_email": true
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
@@ -1710,6 +1732,10 @@ Order payment endpoints
|
||||
|
||||
Payments can now be created through the API.
|
||||
|
||||
.. versionchanged:: 3.12
|
||||
|
||||
The ``confirm`` operation now takes a ``send_email`` parameter.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/
|
||||
|
||||
Returns a list of all payments for an order.
|
||||
@@ -1810,7 +1836,10 @@ Order payment endpoints
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{"force": false}
|
||||
{
|
||||
"send_email": true,
|
||||
"force": false
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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
|
||||
----------------
|
||||
|
||||
@@ -136,7 +136,7 @@ in the ``installed`` method::
|
||||
pass # Your code here
|
||||
|
||||
|
||||
Note that ``installed`` will *not* be called if the plugin in indirectly activated for an event
|
||||
Note that ``installed`` will *not* be called if the plugin is indirectly activated for an event
|
||||
because the event is created with settings copied from another event.
|
||||
|
||||
Views
|
||||
@@ -151,8 +151,8 @@ your Django app label.
|
||||
with checking that the calling user is logged in, has appropriate permissions,
|
||||
etc. We plan on providing native support for this in a later version.
|
||||
|
||||
.. _Django app: https://docs.djangoproject.com/en/1.7/ref/applications/
|
||||
.. _signal dispatcher: https://docs.djangoproject.com/en/1.7/topics/signals/
|
||||
.. _namespace packages: http://legacy.python.org/dev/peps/pep-0420/
|
||||
.. _Django app: https://docs.djangoproject.com/en/3.0/ref/applications/
|
||||
.. _signal dispatcher: https://docs.djangoproject.com/en/3.0/topics/signals/
|
||||
.. _namespace packages: https://legacy.python.org/dev/peps/pep-0420/
|
||||
.. _entry point: https://setuptools.readthedocs.io/en/latest/pkg_resources.html#locating-plugins
|
||||
.. _cookiecutter: https://cookiecutter.readthedocs.io/en/latest/
|
||||
|
||||
@@ -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
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.
|
||||
@@ -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
|
||||
-------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
__version__ = "3.12.0.dev0"
|
||||
|
||||
@@ -3,6 +3,9 @@ from django_scopes import scopes_disabled
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
|
||||
from pretix.api.auth.devicesecurity import (
|
||||
DEVICE_SECURITY_PROFILES, FullAccessSecurityProfile,
|
||||
)
|
||||
from pretix.base.models import Device
|
||||
|
||||
|
||||
@@ -25,3 +28,11 @@ class DeviceTokenAuthentication(TokenAuthentication):
|
||||
raise exceptions.AuthenticationFailed('Device access has been revoked.')
|
||||
|
||||
return AnonymousUser(), device
|
||||
|
||||
def authenticate(self, request):
|
||||
r = super().authenticate(request)
|
||||
if r and isinstance(r[1], Device):
|
||||
profile = DEVICE_SECURITY_PROFILES.get(r[1].security_profile, FullAccessSecurityProfile)
|
||||
if not profile.is_allowed(request):
|
||||
raise exceptions.PermissionDenied('Request denied by device security profile.')
|
||||
return r
|
||||
|
||||
112
src/pretix/api/auth/devicesecurity.py
Normal file
112
src/pretix/api/auth/devicesecurity.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class FullAccessSecurityProfile:
|
||||
identifier = 'full'
|
||||
verbose_name = _('Full access')
|
||||
|
||||
def is_allowed(self, request):
|
||||
return True
|
||||
|
||||
|
||||
class AllowListSecurityProfile:
|
||||
allowlist = tuple()
|
||||
|
||||
def is_allowed(self, request):
|
||||
key = (request.method, f"{request.resolver_match.namespace}:{request.resolver_match.url_name}")
|
||||
return key in self.allowlist
|
||||
|
||||
|
||||
class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
identifier = 'pretixscan'
|
||||
verbose_name = _('pretixSCAN')
|
||||
allowlist = (
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.update'),
|
||||
('GET', 'api-v1:device.revoke'),
|
||||
('GET', 'api-v1:event-list'),
|
||||
('GET', 'api-v1:event-detail'),
|
||||
('GET', 'api-v1:subevent-list'),
|
||||
('GET', 'api-v1:subevent-detail'),
|
||||
('GET', 'api-v1:itemcategory-list'),
|
||||
('GET', 'api-v1:item-list'),
|
||||
('GET', 'api-v1:question-list'),
|
||||
('GET', 'api-v1:badgelayout-list'),
|
||||
('GET', 'api-v1:badgeitem-list'),
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
('GET', 'api-v1:checkinlist-status'),
|
||||
('GET', 'api-v1:checkinlistpos-list'),
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('GET', 'api-v1:order-list'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
)
|
||||
|
||||
|
||||
class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
identifier = 'pretixscan_online_kiosk'
|
||||
verbose_name = _('pretixSCAN (kiosk mode, online only)')
|
||||
allowlist = (
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.update'),
|
||||
('GET', 'api-v1:device.revoke'),
|
||||
('GET', 'api-v1:event-list'),
|
||||
('GET', 'api-v1:event-detail'),
|
||||
('GET', 'api-v1:subevent-list'),
|
||||
('GET', 'api-v1:subevent-detail'),
|
||||
('GET', 'api-v1:itemcategory-list'),
|
||||
('GET', 'api-v1:item-list'),
|
||||
('GET', 'api-v1:question-list'),
|
||||
('GET', 'api-v1:badgelayout-list'),
|
||||
('GET', 'api-v1:badgeitem-list'),
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
('GET', 'api-v1:checkinlist-status'),
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
)
|
||||
|
||||
|
||||
class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
identifier = 'pretixpos'
|
||||
verbose_name = _('pretixPOS')
|
||||
allowlist = (
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.update'),
|
||||
('GET', 'api-v1:device.revoke'),
|
||||
('GET', 'api-v1:event-list'),
|
||||
('GET', 'api-v1:event-detail'),
|
||||
('GET', 'api-v1:subevent-list'),
|
||||
('GET', 'api-v1:subevent-detail'),
|
||||
('GET', 'api-v1:itemcategory-list'),
|
||||
('GET', 'api-v1:item-list'),
|
||||
('GET', 'api-v1:question-list'),
|
||||
('GET', 'api-v1:quota-list'),
|
||||
('GET', 'api-v1:taxrule-list'),
|
||||
('GET', 'api-v1:ticketlayout-list'),
|
||||
('GET', 'api-v1:ticketlayoutitem-list'),
|
||||
('GET', 'api-v1:order-list'),
|
||||
('POST', 'api-v1:order-list'),
|
||||
('GET', 'api-v1:order-detail'),
|
||||
('DELETE', 'api-v1:orderposition-detail'),
|
||||
('POST', 'api-v1:order-mark_canceled'),
|
||||
('POST', 'api-v1:orderrefund-list'),
|
||||
('POST', 'api-v1:orderrefund-done'),
|
||||
('POST', 'api-v1:cartposition-list'),
|
||||
('DELETE', 'api-v1:cartposition-detail'),
|
||||
('GET', 'api-v1:giftcard-list'),
|
||||
('POST', 'api-v1:giftcard-transact'),
|
||||
('POST', 'plugins:pretix_posbackend:posreceipt-list'),
|
||||
('POST', 'plugins:pretix_posbackend:posclosing-list'),
|
||||
('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
|
||||
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
)
|
||||
|
||||
|
||||
DEVICE_SECURITY_PROFILES = {
|
||||
k.identifier: k() for k in (
|
||||
FullAccessSecurityProfile,
|
||||
PretixScanSecurityProfile,
|
||||
PretixScanNoSyncSecurityProfile,
|
||||
PretixPosSecurityProfile,
|
||||
)
|
||||
}
|
||||
@@ -84,3 +84,15 @@ class EventCRUDPermission(EventPermission):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class ProfilePermission(BasePermission):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
if isinstance(request.auth, OAuthAccessToken):
|
||||
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -9,7 +9,7 @@ from oauth2_provider.settings import oauth2_settings
|
||||
class Validator(OAuth2Validator):
|
||||
|
||||
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
if not getattr(request, 'organizers', None):
|
||||
if not getattr(request, 'organizers', None) and request.scopes != ['profile']:
|
||||
raise FatalClientError('No organizers selected.')
|
||||
|
||||
expires = timezone.now() + timedelta(
|
||||
@@ -18,7 +18,8 @@ class Validator(OAuth2Validator):
|
||||
expires=expires, redirect_uri=request.redirect_uri,
|
||||
scope=" ".join(request.scopes))
|
||||
g.save()
|
||||
g.organizers.add(*request.organizers.all())
|
||||
if request.scopes != ['profile']:
|
||||
g.organizers.add(*request.organizers.all())
|
||||
|
||||
def validate_code(self, client_id, code, client, request, *args, **kwargs):
|
||||
try:
|
||||
@@ -34,12 +35,14 @@ class Validator(OAuth2Validator):
|
||||
return False
|
||||
|
||||
def _create_access_token(self, expires, request, token, source_refresh_token=None):
|
||||
if not getattr(request, 'organizers', None) and not getattr(source_refresh_token, 'access_token'):
|
||||
if not getattr(request, 'organizers', None) and not getattr(source_refresh_token, 'access_token', None) and token["scope"] != 'profile':
|
||||
raise FatalClientError('No organizers selected.')
|
||||
if hasattr(request, 'organizers'):
|
||||
orgs = list(request.organizers.all())
|
||||
else:
|
||||
orgs = list(source_refresh_token.access_token.organizers.all())
|
||||
if token['scope'] != 'profile':
|
||||
if hasattr(request, 'organizers'):
|
||||
orgs = list(request.organizers.all())
|
||||
else:
|
||||
orgs = list(source_refresh_token.access_token.organizers.all())
|
||||
access_token = super()._create_access_token(expires, request, token, source_refresh_token=None)
|
||||
access_token.organizers.add(*orgs)
|
||||
if token['scope'] != 'profile':
|
||||
access_token.organizers.add(*orgs)
|
||||
return access_token
|
||||
|
||||
@@ -17,6 +17,17 @@ class CheckinListSerializer(I18nAwareModelSerializer):
|
||||
'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)
|
||||
event = self.context['event']
|
||||
|
||||
@@ -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:
|
||||
@@ -565,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',
|
||||
@@ -597,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',
|
||||
@@ -611,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',
|
||||
@@ -622,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):
|
||||
@@ -629,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'](
|
||||
@@ -660,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', 'type')
|
||||
fields = ('id', '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(
|
||||
|
||||
@@ -9,7 +9,8 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import CompatibleJSONField
|
||||
from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.models import (
|
||||
GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
|
||||
Device, GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
|
||||
User,
|
||||
)
|
||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
@@ -66,9 +67,6 @@ class EventSlugField(serializers.SlugRelatedField):
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
limit_events = EventSlugField(slug_field='slug', many=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = (
|
||||
@@ -86,6 +84,28 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class DeviceSerializer(serializers.ModelSerializer):
|
||||
limit_events = EventSlugField(slug_field='slug', many=True)
|
||||
device_id = serializers.IntegerField(read_only=True)
|
||||
unique_serial = serializers.CharField(read_only=True)
|
||||
hardware_brand = serializers.CharField(read_only=True)
|
||||
hardware_model = serializers.CharField(read_only=True)
|
||||
software_brand = serializers.CharField(read_only=True)
|
||||
software_version = serializers.CharField(read_only=True)
|
||||
created = serializers.DateTimeField(read_only=True)
|
||||
revoked = serializers.BooleanField(read_only=True)
|
||||
initialized = serializers.DateTimeField(read_only=True)
|
||||
initialization_token = serializers.DateTimeField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = (
|
||||
'device_id', 'unique_serial', 'initialization_token', 'all_events', 'limit_events',
|
||||
'revoked', 'name', 'created', 'initialized', 'hardware_brand', 'hardware_model',
|
||||
'software_brand', 'software_version', 'security_profile'
|
||||
)
|
||||
|
||||
|
||||
class TeamInviteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TeamInvite
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -21,6 +21,7 @@ orga_router.register(r'webhooks', webhooks.WebHookViewSet)
|
||||
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
|
||||
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
|
||||
orga_router.register(r'teams', organizer.TeamViewSet)
|
||||
orga_router.register(r'devices', organizer.DeviceViewSet)
|
||||
|
||||
team_router = routers.DefaultRouter()
|
||||
team_router.register(r'members', organizer.TeamMemberViewSet)
|
||||
@@ -44,7 +45,7 @@ event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
||||
event_router.register(r'cartpositions', cart.CartPositionViewSet)
|
||||
|
||||
checkinlist_router = routers.DefaultRouter()
|
||||
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet)
|
||||
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')
|
||||
|
||||
question_router = routers.DefaultRouter()
|
||||
question_router.register(r'options', item.QuestionOptionViewSet)
|
||||
|
||||
@@ -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
|
||||
@@ -17,6 +18,7 @@ from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import CheckinListOrderPositionSerializer
|
||||
from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Event, Order, OrderPosition,
|
||||
)
|
||||
@@ -27,10 +29,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
|
||||
@@ -79,73 +88,74 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=['GET'])
|
||||
def status(self, *args, **kwargs):
|
||||
clist = self.get_object()
|
||||
cqs = Checkin.objects.filter(
|
||||
position__order__event=clist.event,
|
||||
position__order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []),
|
||||
list=clist
|
||||
)
|
||||
pqs = OrderPosition.objects.filter(
|
||||
order__event=clist.event,
|
||||
order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []),
|
||||
)
|
||||
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))
|
||||
with language(self.request.event.settings.locale):
|
||||
clist = self.get_object()
|
||||
cqs = Checkin.objects.filter(
|
||||
position__order__event=clist.event,
|
||||
position__order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []),
|
||||
list=clist
|
||||
)
|
||||
pqs = OrderPosition.objects.filter(
|
||||
order__event=clist.event,
|
||||
order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []),
|
||||
)
|
||||
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))
|
||||
|
||||
ev = clist.subevent or clist.event
|
||||
response = {
|
||||
'event': {
|
||||
'name': str(ev.name),
|
||||
},
|
||||
'checkin_count': cqs.count(),
|
||||
'position_count': pqs.count()
|
||||
}
|
||||
|
||||
op_by_item = {
|
||||
p['item']: p['cnt']
|
||||
for p in pqs.order_by().values('item').annotate(cnt=Count('id'))
|
||||
}
|
||||
op_by_variation = {
|
||||
p['variation']: p['cnt']
|
||||
for p in pqs.order_by().values('variation').annotate(cnt=Count('id'))
|
||||
}
|
||||
c_by_item = {
|
||||
p['position__item']: p['cnt']
|
||||
for p in cqs.order_by().values('position__item').annotate(cnt=Count('id'))
|
||||
}
|
||||
c_by_variation = {
|
||||
p['position__variation']: p['cnt']
|
||||
for p in cqs.order_by().values('position__variation').annotate(cnt=Count('id'))
|
||||
}
|
||||
|
||||
if not clist.all_products:
|
||||
items = clist.limit_products
|
||||
else:
|
||||
items = clist.event.items
|
||||
|
||||
response['items'] = []
|
||||
for item in items.order_by('category__position', 'position', 'pk').prefetch_related('variations'):
|
||||
i = {
|
||||
'id': item.pk,
|
||||
'name': str(item),
|
||||
'admission': item.admission,
|
||||
'checkin_count': c_by_item.get(item.pk, 0),
|
||||
'position_count': op_by_item.get(item.pk, 0),
|
||||
'variations': []
|
||||
ev = clist.subevent or clist.event
|
||||
response = {
|
||||
'event': {
|
||||
'name': str(ev.name),
|
||||
},
|
||||
'checkin_count': cqs.count(),
|
||||
'position_count': pqs.count()
|
||||
}
|
||||
for var in item.variations.all():
|
||||
i['variations'].append({
|
||||
'id': var.pk,
|
||||
'value': str(var),
|
||||
'checkin_count': c_by_variation.get(var.pk, 0),
|
||||
'position_count': op_by_variation.get(var.pk, 0),
|
||||
})
|
||||
response['items'].append(i)
|
||||
|
||||
return Response(response)
|
||||
op_by_item = {
|
||||
p['item']: p['cnt']
|
||||
for p in pqs.order_by().values('item').annotate(cnt=Count('id'))
|
||||
}
|
||||
op_by_variation = {
|
||||
p['variation']: p['cnt']
|
||||
for p in pqs.order_by().values('variation').annotate(cnt=Count('id'))
|
||||
}
|
||||
c_by_item = {
|
||||
p['position__item']: p['cnt']
|
||||
for p in cqs.order_by().values('position__item').annotate(cnt=Count('id'))
|
||||
}
|
||||
c_by_variation = {
|
||||
p['position__variation']: p['cnt']
|
||||
for p in cqs.order_by().values('position__variation').annotate(cnt=Count('id'))
|
||||
}
|
||||
|
||||
if not clist.all_products:
|
||||
items = clist.limit_products
|
||||
else:
|
||||
items = clist.event.items
|
||||
|
||||
response['items'] = []
|
||||
for item in items.order_by('category__position', 'position', 'pk').prefetch_related('variations'):
|
||||
i = {
|
||||
'id': item.pk,
|
||||
'name': str(item),
|
||||
'admission': item.admission,
|
||||
'checkin_count': c_by_item.get(item.pk, 0),
|
||||
'position_count': op_by_item.get(item.pk, 0),
|
||||
'variations': []
|
||||
}
|
||||
for var in item.variations.all():
|
||||
i['variations'].append({
|
||||
'id': var.pk,
|
||||
'value': str(var),
|
||||
'checkin_count': c_by_variation.get(var.pk, 0),
|
||||
'position_count': op_by_variation.get(var.pk, 0),
|
||||
})
|
||||
response['items'].append(i)
|
||||
|
||||
return Response(response)
|
||||
|
||||
|
||||
with scopes_disabled():
|
||||
@@ -192,7 +202,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
|
||||
@@ -247,12 +257,12 @@ 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
|
||||
@@ -260,13 +270,27 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
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')
|
||||
@@ -302,6 +326,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,
|
||||
@@ -314,11 +346,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
|
||||
|
||||
@@ -35,7 +35,7 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
model = Device
|
||||
fields = [
|
||||
'organizer', 'device_id', 'unique_serial', 'api_token',
|
||||
'name'
|
||||
'name', 'security_profile'
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ 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 (
|
||||
CartPosition, Device, Event, ItemCategory, TaxRule, TeamAPIToken,
|
||||
CartPosition, Device, Event, TaxRule, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
@@ -73,6 +73,7 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
queryset = Event.objects.none()
|
||||
lookup_field = 'slug'
|
||||
lookup_url_kwarg = 'event'
|
||||
lookup_value_regex = '[^/]+'
|
||||
permission_classes = (EventCRUDPermission,)
|
||||
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
|
||||
ordering = ('slug',)
|
||||
@@ -228,7 +229,7 @@ with scopes_disabled():
|
||||
|
||||
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = SubEventSerializer
|
||||
queryset = ItemCategory.objects.none()
|
||||
queryset = SubEvent.objects.none()
|
||||
write_permission = 'can_change_event_settings'
|
||||
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
|
||||
filterset_class = SubEventFilter
|
||||
@@ -337,10 +338,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: {
|
||||
|
||||
@@ -542,6 +542,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
data = {
|
||||
'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],
|
||||
|
||||
@@ -3,8 +3,9 @@ import logging
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
from oauth2_provider.exceptions import OAuthToolkitError
|
||||
from oauth2_provider.exceptions import FatalClientError, OAuthToolkitError
|
||||
from oauth2_provider.forms import AllowForm
|
||||
from oauth2_provider.settings import oauth2_settings
|
||||
from oauth2_provider.views import (
|
||||
AuthorizationView as BaseAuthorizationView,
|
||||
RevokeTokenView as BaseRevokeTokenView, TokenView as BaseTokenView,
|
||||
@@ -24,9 +25,12 @@ class OAuthAllowForm(AllowForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user')
|
||||
scope = kwargs.pop('scope')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['organizers'].queryset = Organizer.objects.filter(
|
||||
pk__in=user.teams.values_list('organizer', flat=True))
|
||||
if scope == 'profile':
|
||||
del self.fields['organizers']
|
||||
|
||||
|
||||
class AuthorizationView(BaseAuthorizationView):
|
||||
@@ -36,6 +40,7 @@ class AuthorizationView(BaseAuthorizationView):
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['user'] = self.request.user
|
||||
kwargs['scope'] = self.request.GET.get('scope')
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@@ -43,8 +48,14 @@ class AuthorizationView(BaseAuthorizationView):
|
||||
ctx['settings'] = settings
|
||||
return ctx
|
||||
|
||||
def create_authorization_response(self, request, scopes, credentials, allow, organizers):
|
||||
credentials["organizers"] = organizers
|
||||
def validate_authorization_request(self, request):
|
||||
require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT)
|
||||
if require_approval != 'force' and request.GET.get('scope') != 'profile':
|
||||
raise FatalClientError('Combnination of require_approval and scope values not allowed.')
|
||||
return super().validate_authorization_request(request)
|
||||
|
||||
def create_authorization_response(self, request, scopes, credentials, allow, organizers=None):
|
||||
credentials["organizers"] = organizers or []
|
||||
return super().create_authorization_response(request, scopes, credentials, allow)
|
||||
|
||||
def form_valid(self, form):
|
||||
|
||||
@@ -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())
|
||||
@@ -172,6 +228,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
@action(detail=True, methods=['POST'])
|
||||
def mark_paid(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
send_mail = request.data.get('send_email', True)
|
||||
|
||||
if order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED):
|
||||
|
||||
@@ -213,6 +270,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
try:
|
||||
p.confirm(auth=self.request.auth,
|
||||
user=self.request.user if request.user.is_authenticated else None,
|
||||
send_mail=send_mail,
|
||||
count_waitinglist=False)
|
||||
except Quota.QuotaExceededException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -488,10 +546,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 +803,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
{
|
||||
"item": 2,
|
||||
"variation": null,
|
||||
"subevent": 3
|
||||
"subevent": 3,
|
||||
"tax_rule": 4,
|
||||
}
|
||||
|
||||
Sample output:
|
||||
@@ -797,7 +858,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 +871,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>[^/]+)')
|
||||
@@ -912,6 +978,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
def confirm(self, request, **kwargs):
|
||||
payment = self.get_object()
|
||||
force = request.data.get('force', False)
|
||||
send_mail = request.data.get('send_email', True)
|
||||
|
||||
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
|
||||
return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -920,6 +987,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
payment.confirm(user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth,
|
||||
count_waitinglist=False,
|
||||
send_mail=send_mail,
|
||||
force=force)
|
||||
except Quota.QuotaExceededException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -6,20 +6,22 @@ from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import filters, serializers, status, viewsets
|
||||
from rest_framework import filters, mixins, serializers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
|
||||
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.serializers.organizer import (
|
||||
GiftCardSerializer, OrganizerSerializer, SeatingPlanSerializer,
|
||||
TeamAPITokenSerializer, TeamInviteSerializer, TeamMemberSerializer,
|
||||
TeamSerializer,
|
||||
DeviceSerializer, GiftCardSerializer, OrganizerSerializer,
|
||||
SeatingPlanSerializer, TeamAPITokenSerializer, TeamInviteSerializer,
|
||||
TeamMemberSerializer, TeamSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
|
||||
Device, GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
|
||||
User,
|
||||
)
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
@@ -29,6 +31,7 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = Organizer.objects.none()
|
||||
lookup_field = 'slug'
|
||||
lookup_url_kwarg = 'organizer'
|
||||
lookup_value_regex = '[^/]+'
|
||||
filter_backends = (filters.OrderingFilter,)
|
||||
ordering = ('slug',)
|
||||
ordering_fields = ('name', 'slug')
|
||||
@@ -352,3 +355,44 @@ class TeamAPITokenViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
|
||||
serializer = self.get_serializer_class()(instance)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK, headers=headers)
|
||||
|
||||
|
||||
class DeviceViewSet(mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
GenericViewSet):
|
||||
serializer_class = DeviceSerializer
|
||||
queryset = Device.objects.none()
|
||||
permission = 'can_change_organizer_settings'
|
||||
write_permission = 'can_change_organizer_settings'
|
||||
lookup_field = 'device_id'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.devices.order_by('pk')
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
inst.log_action(
|
||||
'pretix.device.created',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
inst = serializer.save()
|
||||
inst.log_action(
|
||||
'pretix.device.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
return inst
|
||||
|
||||
@@ -3,14 +3,18 @@ from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from pretix.api.auth.permission import ProfilePermission
|
||||
|
||||
|
||||
class MeView(APIView):
|
||||
authentication_classes = (SessionAuthentication, OAuth2Authentication)
|
||||
permission_classes = (ProfilePermission,)
|
||||
|
||||
def get(self, request, format=None):
|
||||
return Response({
|
||||
'email': request.user.email,
|
||||
'fullname': request.user.fullname,
|
||||
'locale': request.user.locale,
|
||||
'is_staff': request.user.is_staff,
|
||||
'timezone': request.user.timezone
|
||||
})
|
||||
|
||||
@@ -85,6 +85,8 @@ class ParametrizedOrderWebhookEvent(WebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
order = logentry.content_object
|
||||
if not order:
|
||||
return None
|
||||
|
||||
return {
|
||||
'notification_id': logentry.pk,
|
||||
@@ -99,6 +101,8 @@ class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
d = super().build_payload(logentry)
|
||||
if d is None:
|
||||
return None
|
||||
d['orderposition_id'] = logentry.parsed_data.get('position')
|
||||
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
|
||||
d['checkin_list'] = logentry.parsed_data.get('list')
|
||||
@@ -218,6 +222,10 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
|
||||
return # Ignore, e.g. plugin not installed
|
||||
|
||||
payload = event_type.build_payload(logentry)
|
||||
if payload is None:
|
||||
# Content object deleted?
|
||||
return
|
||||
|
||||
t = time.time()
|
||||
|
||||
try:
|
||||
|
||||
@@ -98,7 +98,10 @@ class BaseAuthBackend:
|
||||
|
||||
class NativeAuthBackend(BaseAuthBackend):
|
||||
identifier = 'native'
|
||||
verbose_name = _('pretix User')
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('{system} User').format(system=settings.PRETIX_INSTANCE_NAME)
|
||||
|
||||
@property
|
||||
def login_form_fields(self) -> dict:
|
||||
|
||||
@@ -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 (
|
||||
@@ -112,7 +114,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
'site_url': settings.SITE_URL,
|
||||
'body': body_md,
|
||||
'subject': str(subject),
|
||||
'color': '#8E44B3',
|
||||
'color': settings.PRETIX_PRIMARY_COLOR,
|
||||
'rtl': get_language() in settings.LANGUAGES_RTL
|
||||
}
|
||||
if self.event:
|
||||
@@ -220,6 +222,7 @@ class SimpleFunctionalMailTextPlaceholder(BaseMailTextPlaceholder):
|
||||
def get_available_placeholders(event, base_parameters):
|
||||
if 'order' in base_parameters:
|
||||
base_parameters.append('invoice_address')
|
||||
base_parameters.append('position_or_address')
|
||||
params = {}
|
||||
for r, val in register_mail_placeholders.send(sender=event):
|
||||
if not isinstance(val, (list, tuple)):
|
||||
@@ -238,7 +241,9 @@ def get_email_context(**kwargs):
|
||||
try:
|
||||
kwargs['invoice_address'] = kwargs['order'].invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
kwargs['invoice_address'] = InvoiceAddress()
|
||||
kwargs['invoice_address'] = InvoiceAddress(order=kwargs['order'])
|
||||
finally:
|
||||
kwargs.setdefault("position_or_address", kwargs['invoice_address'])
|
||||
ctx = {}
|
||||
for r, val in register_mail_placeholders.send(sender=event):
|
||||
if not isinstance(val, (list, tuple)):
|
||||
@@ -258,9 +263,31 @@ 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
|
||||
elif position_or_address.order:
|
||||
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 +321,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 +341,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 +500,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 +515,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,27 +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 (
|
||||
@@ -50,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)
|
||||
@@ -70,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),
|
||||
@@ -82,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 += [
|
||||
@@ -119,6 +149,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('Requires special attention'))
|
||||
headers.append(_('Comment'))
|
||||
headers.append(_('Positions'))
|
||||
headers.append(_('Payment providers'))
|
||||
|
||||
yield headers
|
||||
|
||||
@@ -139,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, '')
|
||||
@@ -167,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 '',
|
||||
@@ -189,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'),
|
||||
@@ -219,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,
|
||||
@@ -248,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, '')
|
||||
@@ -263,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'
|
||||
@@ -280,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'))
|
||||
@@ -300,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,8 +388,14 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
pgettext('address', 'State'),
|
||||
_('Voucher'),
|
||||
_('Pseudonymization ID'),
|
||||
_('Seat ID'),
|
||||
_('Seat name'),
|
||||
_('Seat zone'),
|
||||
_('Seat row'),
|
||||
_('Seat number'),
|
||||
]
|
||||
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:
|
||||
@@ -329,7 +409,7 @@ 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 += [
|
||||
@@ -337,97 +417,130 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
]
|
||||
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 += [
|
||||
order.sales_channel,
|
||||
order.locale
|
||||
]
|
||||
yield row
|
||||
|
||||
if op.seat:
|
||||
row += [
|
||||
op.seat.seat_guid,
|
||||
str(op.seat),
|
||||
op.seat.zone_name,
|
||||
op.seat.row_name,
|
||||
op.seat.seat_number,
|
||||
]
|
||||
else:
|
||||
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):
|
||||
@@ -459,31 +572,28 @@ class PaymentListExporter(ListExporter):
|
||||
)
|
||||
|
||||
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')
|
||||
|
||||
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:
|
||||
@@ -491,6 +601,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'),
|
||||
@@ -503,7 +614,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):
|
||||
@@ -513,7 +627,7 @@ 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
|
||||
|
||||
@@ -532,6 +646,7 @@ class QuotaListExporter(ListExporter):
|
||||
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
|
||||
@@ -540,218 +655,34 @@ 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,
|
||||
provider='giftcard'
|
||||
order__event__in=self.events,
|
||||
provider='giftcard',
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
).order_by('created')
|
||||
refunds = OrderRefund.objects.filter(
|
||||
order__event=self.event,
|
||||
provider='giftcard'
|
||||
order__event__in=self.events,
|
||||
provider='giftcard',
|
||||
state=OrderRefund.REFUND_STATE_DONE
|
||||
).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'),
|
||||
@@ -762,7 +693,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")
|
||||
@@ -770,21 +704,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
|
||||
|
||||
@@ -65,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
|
||||
|
||||
@@ -36,8 +36,8 @@ from pretix.base.i18n import language
|
||||
from pretix.base.models import InvoiceAddress, Question, QuestionOption
|
||||
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
|
||||
from pretix.base.settings import (
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SCHEMES,
|
||||
PERSON_NAME_TITLE_GROUPS,
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS,
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.control.forms import ExtFileField, SplitDateTimeField
|
||||
@@ -49,7 +49,7 @@ from pretix.presale.signals import question_form_fields
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
REQUIRED_NAME_PARTS = ['given_name', 'family_name', 'full_name']
|
||||
REQUIRED_NAME_PARTS = ['salutation', 'given_name', 'family_name', 'full_name']
|
||||
|
||||
|
||||
class NamePartsWidget(forms.MultiWidget):
|
||||
@@ -73,6 +73,8 @@ class NamePartsWidget(forms.MultiWidget):
|
||||
a['data-fname'] = fname
|
||||
if fname == 'title' and self.titles:
|
||||
widgets.append(Select(attrs=a, choices=[('', '')] + [(d, d) for d in self.titles[1]]))
|
||||
elif fname == 'salutation':
|
||||
widgets.append(Select(attrs=a, choices=[('', '---')] + [(s, s) for s in PERSON_NAME_SALUTATIONS]))
|
||||
else:
|
||||
widgets.append(self.widget(attrs=a))
|
||||
super().__init__(widgets, attrs)
|
||||
@@ -162,12 +164,18 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
**d,
|
||||
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
|
||||
)
|
||||
field.part_name = fname
|
||||
fields.append(field)
|
||||
|
||||
elif fname == 'salutation':
|
||||
d = dict(defaults)
|
||||
d.pop('max_length', None)
|
||||
field = forms.ChoiceField(
|
||||
**d,
|
||||
choices=[('', '---')] + [(s, s) for s in PERSON_NAME_SALUTATIONS]
|
||||
)
|
||||
else:
|
||||
field = forms.CharField(**defaults)
|
||||
field.part_name = fname
|
||||
fields.append(field)
|
||||
field.part_name = fname
|
||||
fields.append(field)
|
||||
super().__init__(
|
||||
fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
@@ -184,6 +192,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
|
||||
|
||||
|
||||
@@ -244,8 +256,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,
|
||||
@@ -254,7 +268,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),
|
||||
@@ -265,14 +279,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={
|
||||
@@ -282,24 +297,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,
|
||||
@@ -324,7 +341,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,
|
||||
@@ -332,7 +349,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
|
||||
@@ -385,13 +409,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(
|
||||
@@ -485,7 +510,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
|
||||
@@ -532,7 +562,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
|
||||
@@ -566,7 +597,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 = {
|
||||
@@ -616,6 +647,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
|
||||
@@ -630,8 +666,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,
|
||||
@@ -663,6 +697,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.'))
|
||||
@@ -683,7 +720,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'):
|
||||
|
||||
@@ -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,18 +391,18 @@ 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' +
|
||||
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
|
||||
from_date=self.invoice.event.get_date_from_display(),
|
||||
to_date=self.invoice.event.get_date_to_display()
|
||||
from_date=self.invoice.event.get_date_from_display(show_times=False),
|
||||
to_date=self.invoice.event.get_date_to_display(show_times=False)
|
||||
)
|
||||
)
|
||||
else:
|
||||
p_str = (
|
||||
shorten(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display()
|
||||
shorten(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display(show_times=False)
|
||||
)
|
||||
else:
|
||||
p_str = shorten(self.invoice.event.name)
|
||||
|
||||
@@ -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']))
|
||||
|
||||
|
||||
31
src/pretix/base/migrations/0154_auto_20200620_1633.py
Normal file
31
src/pretix/base/migrations/0154_auto_20200620_1633.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 3.0.6 on 2020-06-20 16:33
|
||||
|
||||
import django_countries.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.countries
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0153_auto_20200528_1953'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='subeventitem',
|
||||
name='disabled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subeventitemvariation',
|
||||
name='disabled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoiceaddress',
|
||||
name='country',
|
||||
field=django_countries.fields.CountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2),
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0155_quota_release_after_exit.py
Normal file
18
src/pretix/base/migrations/0155_quota_release_after_exit.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.6 on 2020-06-26 14:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0154_auto_20200620_1633'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='quota',
|
||||
name='release_after_exit',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.6 on 2020-06-28 19:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0155_quota_release_after_exit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='override_tax_rate',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
|
||||
),
|
||||
]
|
||||
20
src/pretix/base/migrations/0157_auto_20200712_0932.py
Normal file
20
src/pretix/base/migrations/0157_auto_20200712_0932.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.0.6 on 2020-07-12 09:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.countries
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0156_cartposition_override_tax_rate'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='multi_allowed',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
51
src/pretix/base/migrations/0158_auto_20200724_0754.py
Normal file
51
src/pretix/base/migrations/0158_auto_20200724_0754.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 3.0.8 on 2020-07-24 07:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
import pretix.helpers.countries
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0157_auto_20200712_0932'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='cachedfile',
|
||||
name='file',
|
||||
field=models.FileField(max_length=255, null=True, upload_to=pretix.base.models.base.cachedfile_name),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='country',
|
||||
field=pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_country',
|
||||
field=pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_country',
|
||||
field=pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoiceaddress',
|
||||
name='country',
|
||||
field=pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='country',
|
||||
field=pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='taxrule',
|
||||
name='home_country',
|
||||
field=pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2),
|
||||
),
|
||||
]
|
||||
40
src/pretix/base/migrations/0159_mails_by_sales_channel.py
Normal file
40
src/pretix/base/migrations/0159_mails_by_sales_channel.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 3.0.8 on 2020-07-24 09:05
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
|
||||
|
||||
def set_sales_channels(apps, schema_editor):
|
||||
# We now allow restricting some mails to certain sales channels
|
||||
# The default is changing from all channels to "web" only
|
||||
# Therefore, for existing events, we enable all sales channels
|
||||
Event_SettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
|
||||
Event = apps.get_model('pretixbase', 'Event')
|
||||
all_sales_channels = "[" + ", ".join('"' + sc + '"' for sc in get_all_sales_channels()) + "]"
|
||||
batch_size = 1000
|
||||
Event_SettingsStore.objects.bulk_create([
|
||||
Event_SettingsStore(
|
||||
object=event,
|
||||
key="mail_sales_channel_placed_paid",
|
||||
value=all_sales_channels)
|
||||
for event in Event.objects.all()
|
||||
], batch_size=batch_size)
|
||||
Event_SettingsStore.objects.bulk_create([
|
||||
Event_SettingsStore(
|
||||
object=event,
|
||||
key="mail_sales_channel_download_reminder",
|
||||
value=all_sales_channels)
|
||||
for event in Event.objects.all()
|
||||
], batch_size=batch_size)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0158_auto_20200724_0754'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_sales_channels, migrations.RunPython.noop),
|
||||
]
|
||||
28
src/pretix/base/migrations/0160_multiple_confirm_texts.py
Normal file
28
src/pretix/base/migrations/0160_multiple_confirm_texts.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.0.8 on 2020-07-31 10:05
|
||||
|
||||
import json
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_confirm_text(apps, schema_editor):
|
||||
# We now allow creating multiple confirm texts so we migrate the setting for that
|
||||
# from `confirm_text` to `confirm_texts`
|
||||
Event_SettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
|
||||
for store in Event_SettingsStore.objects.filter(key="confirm_text"):
|
||||
if store.value:
|
||||
values = json.dumps([json.loads(store.value)]) # convert single value to one-element list
|
||||
store.key = "confirm_texts"
|
||||
store.value = values
|
||||
store.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0159_mails_by_sales_channel'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_confirm_text, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_change_allow_user_price(apps, schema_editor):
|
||||
# Previously, the "gt" value was meant to represent "greater or equal", which became an issue the moment
|
||||
# we introduced a "greater" and "greater or equal" option. This migrates any previous "greater or equal"
|
||||
# selection to the new "gte".
|
||||
Event_SettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
|
||||
Event_SettingsStore.objects.filter(key="change_allow_user_price", value="gt").update(value="gte")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0160_multiple_confirm_texts'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_change_allow_user_price, migrations.RunPython.noop),
|
||||
]
|
||||
17
src/pretix/base/migrations/0162_remove_seat_name.py
Normal file
17
src/pretix/base/migrations/0162_remove_seat_name.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.0.9 on 2020-08-24 07:29
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0161_order_changes_retain_old_default'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='seat',
|
||||
name='name',
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0163_device_security_profile.py
Normal file
18
src/pretix/base/migrations/0163_device_security_profile.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.9 on 2020-10-13 08:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0162_remove_seat_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='security_profile',
|
||||
field=models.CharField(default='full', max_length=190, null=True),
|
||||
),
|
||||
]
|
||||
@@ -173,7 +173,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
return self.email
|
||||
|
||||
def send_security_notice(self, messages, email=None):
|
||||
from pretix.base.services.mail import mail, SendMailException
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
|
||||
try:
|
||||
with language(self.locale):
|
||||
|
||||
@@ -27,7 +27,7 @@ class CachedFile(models.Model):
|
||||
date = models.DateTimeField(null=True, blank=True)
|
||||
filename = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=255)
|
||||
file = models.FileField(null=True, blank=True, upload_to=cachedfile_name)
|
||||
file = models.FileField(null=True, blank=True, upload_to=cachedfile_name, max_length=255)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=CachedFile)
|
||||
@@ -48,14 +48,15 @@ class LoggingMixin:
|
||||
:param data: Any JSON-serializable object
|
||||
:param user: The user performing the action (optional)
|
||||
"""
|
||||
from .log import LogEntry
|
||||
from .event import Event
|
||||
from .devices import Device
|
||||
from pretix.api.models import OAuthAccessToken, OAuthApplication
|
||||
from .organizer import TeamAPIToken
|
||||
from pretix.api.webhooks import get_all_webhook_events, notify_webhooks
|
||||
|
||||
from ..notifications import get_all_notification_types
|
||||
from ..services.notifications import notify
|
||||
from pretix.api.webhooks import get_all_webhook_events, notify_webhooks
|
||||
from .devices import Device
|
||||
from .event import Event
|
||||
from .log import LogEntry
|
||||
from .organizer import TeamAPIToken
|
||||
|
||||
event = None
|
||||
if isinstance(self, Event):
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Exists, F, Max, OuterRef, Q, Subquery
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
@@ -47,7 +48,7 @@ class CheckinList(LoggedModel):
|
||||
|
||||
@property
|
||||
def positions(self):
|
||||
from . import OrderPosition, Order
|
||||
from . import Order, OrderPosition
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.event,
|
||||
@@ -89,11 +90,14 @@ class CheckinList(LoggedModel):
|
||||
).count()
|
||||
|
||||
@property
|
||||
@scopes_disabled()
|
||||
# Disable scopes, because this query is safe and the additional organizer filter in the EXISTS() subquery tricks PostgreSQL into a bad
|
||||
# subplan that sequentially scans all events
|
||||
def checkin_count(self):
|
||||
return self.event.cache.get_or_set(
|
||||
'checkin_list_{}_checkin_count'.format(self.pk),
|
||||
lambda: self.positions.annotate(
|
||||
checkedin=Exists(Checkin.objects.filter(list_id=self.pk, position=OuterRef('pk')))
|
||||
lambda: self.positions.using(settings.DATABASE_REPLICA).annotate(
|
||||
checkedin=Exists(Checkin.objects.filter(list_id=self.pk, position=OuterRef('pk'), type=Checkin.TYPE_ENTRY,))
|
||||
).filter(
|
||||
checkedin=True
|
||||
).count(),
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.api.auth.devicesecurity import DEVICE_SECURITY_PROFILES
|
||||
from pretix.base.models import LoggedModel
|
||||
|
||||
|
||||
@@ -74,6 +75,13 @@ class Device(LoggedModel):
|
||||
max_length=190,
|
||||
null=True, blank=True
|
||||
)
|
||||
security_profile = models.CharField(
|
||||
max_length=190,
|
||||
choices=[(k, v.verbose_name) for k, v in DEVICE_SECURITY_PROFILES.items()],
|
||||
default='full',
|
||||
null=True,
|
||||
blank=False
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='organizer')
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.core.files.storage import default_storage
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery, Value
|
||||
from django.template.defaultfilters import date as _date
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.formats import date_format
|
||||
@@ -89,7 +89,7 @@ class EventMixin:
|
||||
self.date_from.astimezone(tz), "TIME_FORMAT"
|
||||
)
|
||||
|
||||
def get_date_to_display(self, tz=None, short=False) -> str:
|
||||
def get_date_to_display(self, tz=None, show_times=True, short=False) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start date of the event with respect
|
||||
to the current locale and to the ``show_times`` setting. Returns an empty string
|
||||
@@ -100,14 +100,14 @@ class EventMixin:
|
||||
return ""
|
||||
return _date(
|
||||
self.date_to.astimezone(tz),
|
||||
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT")
|
||||
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT")
|
||||
)
|
||||
|
||||
def get_date_range_display(self, tz=None, force_show_end=False) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start date and the end date
|
||||
of the event with respect to the current locale and to the ``show_times`` and
|
||||
``show_date_to`` settings.
|
||||
of the event with respect to the current locale and to the ``show_date_to``
|
||||
setting. Times are not shown.
|
||||
"""
|
||||
tz = tz or self.timezone
|
||||
if (not self.settings.show_date_to and not force_show_end) or not self.date_to:
|
||||
@@ -189,7 +189,9 @@ class EventMixin:
|
||||
).order_by().values_list('quotas__pk').annotate(
|
||||
items=GroupConcat('pk', delimiter=',')
|
||||
).values('items')
|
||||
return qs.prefetch_related(
|
||||
return qs.annotate(
|
||||
has_paid_item=Exists(Item.objects.filter(event_id=OuterRef(cls._event_id), default_price__gt=0))
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
'quotas',
|
||||
to_attr='active_quotas',
|
||||
@@ -280,6 +282,7 @@ class Event(EventMixin, LoggedModel):
|
||||
"""
|
||||
|
||||
settings_namespace = 'event'
|
||||
_event_id = 'pk'
|
||||
CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES]
|
||||
organizer = models.ForeignKey(Organizer, related_name="events", on_delete=models.PROTECT)
|
||||
testmode = models.BooleanField(default=False)
|
||||
@@ -375,6 +378,7 @@ class Event(EventMixin, LoggedModel):
|
||||
self.settings.invoice_include_expire_date = True
|
||||
self.settings.ticketoutput_pdf__enabled = True
|
||||
self.settings.ticketoutput_passbook__enabled = True
|
||||
self.settings.event_list_type = 'calendar'
|
||||
|
||||
@property
|
||||
def social_image(self):
|
||||
@@ -414,7 +418,7 @@ class Event(EventMixin, LoggedModel):
|
||||
return super().presale_has_ended
|
||||
|
||||
def delete_all_orders(self, really=False):
|
||||
from .orders import OrderRefund, OrderPayment, OrderPosition, OrderFee
|
||||
from .orders import OrderFee, OrderPayment, OrderPosition, OrderRefund
|
||||
|
||||
if not really:
|
||||
raise TypeError("Pass really=True as a parameter.")
|
||||
@@ -501,8 +505,10 @@ class Event(EventMixin, LoggedModel):
|
||||
), tz)
|
||||
|
||||
def copy_data_from(self, other):
|
||||
from . import ItemAddOn, ItemCategory, Item, Question, Quota, ItemMetaValue
|
||||
from ..signals import event_copy_data
|
||||
from . import (
|
||||
Item, ItemAddOn, ItemCategory, ItemMetaValue, Question, Quota,
|
||||
)
|
||||
|
||||
self.plugins = other.plugins
|
||||
self.is_public = other.is_public
|
||||
@@ -783,7 +789,12 @@ class Event(EventMixin, LoggedModel):
|
||||
'name_ascending': ('name', 'date_from'),
|
||||
'name_descending': ('-name', 'date_from'),
|
||||
}[ordering]
|
||||
subevs = queryset.filter(
|
||||
subevs = queryset.annotate(
|
||||
has_paid_item=Value(
|
||||
self.cache.get_or_set('has_paid_item', lambda: self.items.filter(default_price__gt=0).exists(), 3600),
|
||||
output_field=models.BooleanField()
|
||||
)
|
||||
).filter(
|
||||
Q(active=True) & Q(is_public=True) & (
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now() - timedelta(hours=24)))
|
||||
| Q(date_to__gte=now() - timedelta(hours=24))
|
||||
@@ -882,6 +893,7 @@ class Event(EventMixin, LoggedModel):
|
||||
def delete_sub_objects(self):
|
||||
self.cartposition_set.filter(addon_to__isnull=False).delete()
|
||||
self.cartposition_set.all().delete()
|
||||
self.vouchers.all().delete()
|
||||
self.items.all().delete()
|
||||
self.subevents.all().delete()
|
||||
|
||||
@@ -976,6 +988,7 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
:type location: str
|
||||
"""
|
||||
|
||||
_event_id = 'event_id'
|
||||
event = models.ForeignKey(Event, related_name="subevents", on_delete=models.PROTECT)
|
||||
active = models.BooleanField(default=False, verbose_name=_("Active"),
|
||||
help_text=_("Only with this checkbox enabled, this date is visible in the "
|
||||
@@ -1060,21 +1073,35 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
return self.event.settings
|
||||
|
||||
@cached_property
|
||||
def item_price_overrides(self):
|
||||
def item_overrides(self):
|
||||
from .items import SubEventItem
|
||||
|
||||
return {
|
||||
si.item_id: si.price
|
||||
for si in SubEventItem.objects.filter(subevent=self, price__isnull=False)
|
||||
si.item_id: si
|
||||
for si in SubEventItem.objects.filter(subevent=self)
|
||||
}
|
||||
|
||||
@cached_property
|
||||
def var_price_overrides(self):
|
||||
def var_overrides(self):
|
||||
from .items import SubEventItemVariation
|
||||
|
||||
return {
|
||||
si.variation_id: si
|
||||
for si in SubEventItemVariation.objects.filter(subevent=self)
|
||||
}
|
||||
|
||||
@property
|
||||
def item_price_overrides(self):
|
||||
return {
|
||||
si.item_id: si.price
|
||||
for si in self.item_overrides.values() if si.price is not None
|
||||
}
|
||||
|
||||
@property
|
||||
def var_price_overrides(self):
|
||||
return {
|
||||
si.variation_id: si.price
|
||||
for si in SubEventItemVariation.objects.filter(subevent=self, price__isnull=False)
|
||||
for si in self.var_overrides.values() if si.price is not None
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -1096,12 +1123,28 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
if self.event and clear_cache:
|
||||
self.event.cache.clear()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__original_dates = (self.date_from, self.date_to)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
from .orders import Order
|
||||
|
||||
clear_cache = kwargs.pop('clear_cache', False)
|
||||
super().save(*args, **kwargs)
|
||||
if self.event and clear_cache:
|
||||
self.event.cache.clear()
|
||||
|
||||
if (self.date_from, self.date_to) != self.__original_dates:
|
||||
"""
|
||||
This is required to guarantee a synchronization invariant of our scanning apps.
|
||||
Our syncing apps throw away order records of subevents more than X days ago, since
|
||||
they are not interesting for ticket scanning and pose a performance hazard. However,
|
||||
the app needs to know when a subevent is moved to a date in the future, since that
|
||||
might require it to re-download and re-store the orders.
|
||||
"""
|
||||
Order.objects.filter(all_positions__subevent=self).update(last_modified=now())
|
||||
|
||||
@staticmethod
|
||||
def clean_items(event, items):
|
||||
for item in items:
|
||||
@@ -1168,8 +1211,8 @@ class RequiredAction(models.Model):
|
||||
created = not self.pk
|
||||
super().save(*args, **kwargs)
|
||||
if created:
|
||||
from .log import LogEntry
|
||||
from ..services.notifications import notify
|
||||
from .log import LogEntry
|
||||
|
||||
logentry = LogEntry.objects.create(
|
||||
content_object=self,
|
||||
|
||||
@@ -9,10 +9,10 @@ from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import pgettext
|
||||
from django_countries.fields import CountryField
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
from pretix.helpers.countries import FastCountryField
|
||||
|
||||
|
||||
def invoice_filename(instance, filename: str) -> str:
|
||||
@@ -84,7 +84,7 @@ class Invoice(models.Model):
|
||||
invoice_from_name = models.CharField(max_length=190, null=True)
|
||||
invoice_from_zipcode = models.CharField(max_length=190, null=True)
|
||||
invoice_from_city = models.CharField(max_length=190, null=True)
|
||||
invoice_from_country = CountryField(null=True)
|
||||
invoice_from_country = FastCountryField(null=True)
|
||||
invoice_from_tax_id = models.CharField(max_length=190, null=True)
|
||||
invoice_from_vat_id = models.CharField(max_length=190, null=True)
|
||||
invoice_to = models.TextField()
|
||||
@@ -94,7 +94,7 @@ class Invoice(models.Model):
|
||||
invoice_to_zipcode = models.CharField(max_length=190, null=True)
|
||||
invoice_to_city = models.TextField(null=True)
|
||||
invoice_to_state = models.CharField(max_length=190, null=True)
|
||||
invoice_to_country = CountryField(null=True)
|
||||
invoice_to_country = FastCountryField(null=True)
|
||||
invoice_to_vat_id = models.TextField(null=True)
|
||||
invoice_to_beneficiary = models.TextField(null=True)
|
||||
date = models.DateField(default=today)
|
||||
@@ -116,8 +116,8 @@ class Invoice(models.Model):
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
@staticmethod
|
||||
def _to_numeric_invoice_number(number):
|
||||
return '{:05d}'.format(int(number))
|
||||
def _to_numeric_invoice_number(number, places):
|
||||
return ('{:0%dd}' % places).format(int(number))
|
||||
|
||||
@property
|
||||
def full_invoice_from(self):
|
||||
@@ -173,7 +173,7 @@ class Invoice(models.Model):
|
||||
]
|
||||
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
||||
|
||||
def _get_numeric_invoice_number(self):
|
||||
def _get_numeric_invoice_number(self, c_length):
|
||||
numeric_invoices = Invoice.objects.filter(
|
||||
event__organizer=self.event.organizer,
|
||||
prefix=self.prefix,
|
||||
@@ -182,7 +182,7 @@ class Invoice(models.Model):
|
||||
).aggregate(
|
||||
max=Max('numeric_number')
|
||||
)['max'] or 0
|
||||
return self._to_numeric_invoice_number(numeric_invoices + 1)
|
||||
return self._to_numeric_invoice_number(numeric_invoices + 1, c_length)
|
||||
|
||||
def _get_invoice_number_from_order(self):
|
||||
return '{order}-{count}'.format(
|
||||
@@ -209,7 +209,7 @@ class Invoice(models.Model):
|
||||
self.prefix += 'TEST-'
|
||||
for i in range(10):
|
||||
if self.event.settings.get('invoice_numbers_consecutive'):
|
||||
self.invoice_no = self._get_numeric_invoice_number()
|
||||
self.invoice_no = self._get_numeric_invoice_number(self.event.settings.invoice_numbers_counter_length)
|
||||
else:
|
||||
self.invoice_no = self._get_invoice_number_from_order()
|
||||
try:
|
||||
|
||||
@@ -118,6 +118,7 @@ class SubEventItem(models.Model):
|
||||
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
|
||||
item = models.ForeignKey('Item', on_delete=models.CASCADE)
|
||||
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
|
||||
disabled = models.BooleanField(default=False, verbose_name=_('Disable product for this date'))
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
@@ -145,6 +146,7 @@ class SubEventItemVariation(models.Model):
|
||||
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
|
||||
variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE)
|
||||
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
|
||||
disabled = models.BooleanField(default=False)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
@@ -376,9 +378,9 @@ class Item(LoggedModel):
|
||||
'but only for fixed bundles!')
|
||||
)
|
||||
allow_cancel = models.BooleanField(
|
||||
verbose_name=_('Allow product to be canceled'),
|
||||
verbose_name=_('Allow product to be canceled or changed'),
|
||||
default=True,
|
||||
help_text=_('If this is checked, the usual cancellation settings of this event apply. If this is unchecked, '
|
||||
help_text=_('If this is checked, the usual cancellation and order change settings of this event apply. If this is unchecked, '
|
||||
'orders containing this product can not be canceled by users but only by you.')
|
||||
)
|
||||
min_per_order = models.IntegerField(
|
||||
@@ -410,7 +412,8 @@ class Item(LoggedModel):
|
||||
)
|
||||
sales_channels = fields.MultiStringField(
|
||||
verbose_name=_('Sales channels'),
|
||||
default=['web']
|
||||
default=['web'],
|
||||
blank=True,
|
||||
)
|
||||
issue_giftcard = models.BooleanField(
|
||||
verbose_name=_('This product is a gift card'),
|
||||
@@ -446,24 +449,32 @@ class Item(LoggedModel):
|
||||
return self.event.settings.show_quota_left
|
||||
return self.show_quota_left
|
||||
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False):
|
||||
price = price if price is not None else self.default_price
|
||||
|
||||
if not self.tax_rule:
|
||||
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
|
||||
rate=Decimal('0.00'), name='')
|
||||
else:
|
||||
t = self.tax_rule.tax(price, base_price_is=base_price_is,
|
||||
currency=currency or self.event.currency)
|
||||
t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address,
|
||||
override_tax_rate=override_tax_rate, currency=currency or self.event.currency)
|
||||
|
||||
if include_bundled:
|
||||
for b in self.bundles.all():
|
||||
if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id:
|
||||
if b.bundled_variation:
|
||||
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross',
|
||||
invoice_address=invoice_address,
|
||||
currency=currency)
|
||||
else:
|
||||
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
compare_price = self.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
bprice = b.bundled_item.tax(b.designated_price * b.count,
|
||||
invoice_address=invoice_address,
|
||||
base_price_is='gross',
|
||||
currency=currency)
|
||||
compare_price = self.tax_rule.tax(b.designated_price * b.count,
|
||||
override_tax_rate=override_tax_rate,
|
||||
invoice_address=invoice_address,
|
||||
currency=currency)
|
||||
t.net += bprice.net - compare_price.net
|
||||
t.tax += bprice.tax - compare_price.tax
|
||||
t.name = "MIXED!"
|
||||
@@ -671,23 +682,31 @@ class ItemVariation(models.Model):
|
||||
def price(self):
|
||||
return self.default_price if self.default_price is not None else self.item.default_price
|
||||
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False, override_tax_rate=None,
|
||||
invoice_address=None):
|
||||
price = price if price is not None else self.price
|
||||
|
||||
if not self.item.tax_rule:
|
||||
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
|
||||
rate=Decimal('0.00'), name='')
|
||||
else:
|
||||
t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency)
|
||||
t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency,
|
||||
override_tax_rate=override_tax_rate,
|
||||
invoice_address=invoice_address)
|
||||
|
||||
if include_bundled:
|
||||
for b in self.item.bundles.all():
|
||||
if b.designated_price and b.bundled_item.tax_rule_id != self.item.tax_rule_id:
|
||||
if b.bundled_variation:
|
||||
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross',
|
||||
currency=currency,
|
||||
invoice_address=invoice_address)
|
||||
else:
|
||||
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
compare_price = self.item.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross',
|
||||
currency=currency,
|
||||
invoice_address=invoice_address)
|
||||
compare_price = self.item.tax_rule.tax(b.designated_price * b.count, base_price_is='gross',
|
||||
currency=currency, invoice_address=invoice_address)
|
||||
t.net += bprice.net - compare_price.net
|
||||
t.tax += bprice.tax - compare_price.tax
|
||||
t.name = "MIXED!"
|
||||
@@ -822,6 +841,10 @@ class ItemAddOn(models.Model):
|
||||
help_text=_('If selected, adding add-ons to this ticket is free, even if the add-ons would normally cost '
|
||||
'money individually.')
|
||||
)
|
||||
multi_allowed = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Allow the same product to be selected multiple times'),
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
@@ -1117,6 +1140,8 @@ class Question(LoggedModel):
|
||||
return None
|
||||
|
||||
if self.type == Question.TYPE_CHOICE:
|
||||
if isinstance(answer, QuestionOption):
|
||||
return answer
|
||||
q = Q(identifier=answer)
|
||||
if isinstance(answer, int) or answer.isdigit():
|
||||
q |= Q(pk=answer)
|
||||
@@ -1131,6 +1156,8 @@ class Question(LoggedModel):
|
||||
Q(identifier__in=answer.split(","))
|
||||
))
|
||||
llen = len(answer.split(','))
|
||||
elif all(isinstance(o, QuestionOption) for o in answer):
|
||||
return o
|
||||
else:
|
||||
l_ = list(self.options.filter(
|
||||
Q(pk__in=[a for a in answer if isinstance(a, int) or a.isdigit()]) |
|
||||
@@ -1333,6 +1360,16 @@ class Quota(LoggedModel):
|
||||
)
|
||||
closed = models.BooleanField(default=False)
|
||||
|
||||
release_after_exit = models.BooleanField(
|
||||
verbose_name=_('Allow to sell more tickets once people have checked out'),
|
||||
help_text=_('With this option, quota will be released as soon as people are scanned at an exit of your event. '
|
||||
'This will only happen if they have been scanned both at an entry and at an exit and the exit '
|
||||
'is the more recent scan. It does not matter which check-in list either of the scans was on, '
|
||||
'but check-in lists are ignored if they are set to "Allow re-entering after an exit scan" to '
|
||||
'prevent accidental overbooking.'),
|
||||
default=False,
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -75,7 +75,10 @@ class LogEntry(models.Model):
|
||||
|
||||
@cached_property
|
||||
def display_object(self):
|
||||
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event, TaxRule, SubEvent
|
||||
from . import (
|
||||
Event, Item, ItemCategory, Order, Question, Quota, SubEvent,
|
||||
TaxRule, Voucher,
|
||||
)
|
||||
|
||||
try:
|
||||
if self.content_type.model_class() is Event:
|
||||
|
||||
@@ -27,7 +27,7 @@ from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries.fields import Country, CountryField
|
||||
from django_countries.fields import Country
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
@@ -44,7 +44,7 @@ from pretix.base.services.locking import NoLockManager
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import order_gracefully_delete
|
||||
|
||||
from ...helpers.countries import CachedCountries
|
||||
from ...helpers.countries import CachedCountries, FastCountryField
|
||||
from .base import LockModel, LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Question, QuestionOption, Quota
|
||||
@@ -209,7 +209,7 @@ class Order(LockModel, LoggedModel):
|
||||
return self.full_code
|
||||
|
||||
def gracefully_delete(self, user=None, auth=None):
|
||||
from . import Voucher, GiftCard, GiftCardTransaction
|
||||
from . import GiftCard, GiftCardTransaction, Voucher
|
||||
|
||||
if not self.testmode:
|
||||
raise TypeError("Only test mode orders can be deleted.")
|
||||
@@ -392,13 +392,19 @@ class Order(LockModel, LoggedModel):
|
||||
def set_expires(self, now_dt=None, subevents=None):
|
||||
now_dt = now_dt or now()
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get('payment_term_days', as_type=int))
|
||||
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0)
|
||||
if self.event.settings.get('payment_term_weekdays'):
|
||||
if exp_by_date.weekday() == 5:
|
||||
exp_by_date += timedelta(days=2)
|
||||
elif exp_by_date.weekday() == 6:
|
||||
exp_by_date += timedelta(days=1)
|
||||
mode = self.event.settings.get('payment_term_mode')
|
||||
if mode == 'days':
|
||||
exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get('payment_term_days', as_type=int))
|
||||
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0)
|
||||
if self.event.settings.get('payment_term_weekdays'):
|
||||
if exp_by_date.weekday() == 5:
|
||||
exp_by_date += timedelta(days=2)
|
||||
elif exp_by_date.weekday() == 6:
|
||||
exp_by_date += timedelta(days=1)
|
||||
elif mode == 'minutes':
|
||||
exp_by_date = now_dt.astimezone(tz) + timedelta(minutes=self.event.settings.get('payment_term_minutes', as_type=int))
|
||||
else:
|
||||
raise ValueError("'payment_term_mode' has an invalid value '{}'.".format(mode))
|
||||
|
||||
self.expires = exp_by_date
|
||||
|
||||
@@ -434,6 +440,19 @@ class Order(LockModel, LoggedModel):
|
||||
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED) and self.count_positions
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def user_change_deadline(self):
|
||||
until = self.event.settings.get('change_allow_user_until', as_type=RelativeDateWrapper)
|
||||
if until:
|
||||
if self.event.has_subevents:
|
||||
terms = [
|
||||
until.datetime(se)
|
||||
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||
]
|
||||
return min(terms) if terms else None
|
||||
else:
|
||||
return until.datetime(self.event)
|
||||
|
||||
@cached_property
|
||||
def user_cancel_deadline(self):
|
||||
if self.status == Order.STATUS_PAID and self.total != Decimal('0.00'):
|
||||
@@ -466,6 +485,40 @@ class Order(LockModel, LoggedModel):
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||
return round_decimal(fee, self.event.currency)
|
||||
|
||||
@property
|
||||
@scopes_disabled()
|
||||
def user_change_allowed(self) -> bool:
|
||||
"""
|
||||
Returns whether or not this order can be canceled by the user.
|
||||
"""
|
||||
from .checkin import Checkin
|
||||
|
||||
if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID) or not self.count_positions:
|
||||
return False
|
||||
|
||||
if self.cancellation_requests.exists():
|
||||
return False
|
||||
|
||||
if self.require_approval:
|
||||
return False
|
||||
|
||||
positions = list(
|
||||
self.positions.all().annotate(
|
||||
has_variations=Exists(ItemVariation.objects.filter(item_id=OuterRef('item_id'))),
|
||||
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
|
||||
).select_related('item').prefetch_related('issued_gift_cards')
|
||||
)
|
||||
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
|
||||
if not cancelable or not positions:
|
||||
return False
|
||||
for op in positions:
|
||||
if op.issued_gift_cards.all():
|
||||
return False
|
||||
if self.user_change_deadline and now() > self.user_change_deadline:
|
||||
return False
|
||||
|
||||
return self.event.settings.change_allow_user_variation and any([op.has_variations for op in positions])
|
||||
|
||||
@property
|
||||
@scopes_disabled()
|
||||
def user_cancel_allowed(self) -> bool:
|
||||
@@ -474,7 +527,7 @@ class Order(LockModel, LoggedModel):
|
||||
"""
|
||||
from .checkin import Checkin
|
||||
|
||||
if self.cancellation_requests.exists():
|
||||
if self.cancellation_requests.exists() or not self.cancel_allowed():
|
||||
return False
|
||||
positions = list(
|
||||
self.positions.all().annotate(
|
||||
@@ -502,10 +555,16 @@ class Order(LockModel, LoggedModel):
|
||||
# Algorithm to choose which payments are to be refunded to create the least hassle
|
||||
payments = payments or self.payments.filter(state=OrderPayment.PAYMENT_STATE_CONFIRMED)
|
||||
for p in payments:
|
||||
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
|
||||
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
|
||||
p.propose_refund = Decimal('0.00')
|
||||
p.available_amount = p.amount - p.refunded_amount
|
||||
if p.payment_provider:
|
||||
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
|
||||
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
|
||||
p.propose_refund = Decimal('0.00')
|
||||
p.available_amount = p.amount - p.refunded_amount
|
||||
else:
|
||||
p.full_refund_possible = False
|
||||
p.partial_refund_possible = False
|
||||
p.propose_refund = Decimal('0.00')
|
||||
p.available_amount = Decimal('0.00')
|
||||
|
||||
unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible)
|
||||
to_refund = amount
|
||||
@@ -789,7 +848,9 @@ class Order(LockModel, LoggedModel):
|
||||
only be attached for this position and child positions, the link will only point to the
|
||||
position and the attendee email will be used if available.
|
||||
"""
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
from pretix.base.services.mail import (
|
||||
SendMailException, TolerantDict, mail, render_mail,
|
||||
)
|
||||
|
||||
if not self.email:
|
||||
return
|
||||
@@ -804,6 +865,7 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
try:
|
||||
email_content = render_mail(template, context)
|
||||
subject = subject.format_map(TolerantDict(context))
|
||||
mail(
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers=headers, sender=sender,
|
||||
@@ -1070,7 +1132,7 @@ class AbstractPosition(models.Model):
|
||||
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True, null=True)
|
||||
country = CountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
|
||||
country = FastCountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
|
||||
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
@@ -1186,7 +1248,7 @@ class AbstractPosition(models.Model):
|
||||
self.attendee_name,
|
||||
self.company,
|
||||
self.street,
|
||||
self.zipcode + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||
self.country.name
|
||||
]
|
||||
lines = [r.strip() for r in lines if r]
|
||||
@@ -1371,7 +1433,9 @@ class OrderPayment(models.Model):
|
||||
:type mail_text: str
|
||||
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
|
||||
"""
|
||||
from pretix.base.services.invoices import generate_invoice, invoice_qualified
|
||||
from pretix.base.services.invoices import (
|
||||
generate_invoice, invoice_qualified,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
|
||||
@@ -1441,7 +1505,7 @@ class OrderPayment(models.Model):
|
||||
trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment
|
||||
)
|
||||
|
||||
if send_mail:
|
||||
if send_mail and self.order.sales_channel in self.order.event.settings.mail_sales_channel_placed_paid:
|
||||
self._send_paid_mail(invoice, user, mail_text)
|
||||
if self.order.event.settings.mail_send_order_paid_attendee:
|
||||
for p in self.order.positions.all():
|
||||
@@ -1794,7 +1858,10 @@ class OrderFee(models.Model):
|
||||
self.fee_type, self.value
|
||||
)
|
||||
|
||||
def _calculate_tax(self):
|
||||
def _calculate_tax(self, tax_rule=None):
|
||||
if tax_rule:
|
||||
self.tax_rule = tax_rule
|
||||
|
||||
try:
|
||||
ia = self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
@@ -1804,18 +1871,17 @@ class OrderFee(models.Model):
|
||||
self.tax_rule = self.order.event.settings.tax_rate_default
|
||||
|
||||
if self.tax_rule:
|
||||
if self.tax_rule.tax_applicable(ia):
|
||||
tax = self.tax_rule.tax(self.value, base_price_is='gross')
|
||||
self.tax_rate = tax.rate
|
||||
self.tax_value = tax.tax
|
||||
else:
|
||||
self.tax_value = Decimal('0.00')
|
||||
self.tax_rate = Decimal('0.00')
|
||||
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia)
|
||||
self.tax_rate = tax.rate
|
||||
self.tax_value = tax.tax
|
||||
else:
|
||||
self.tax_value = Decimal('0.00')
|
||||
self.tax_rate = Decimal('0.00')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.tax_rule and not self.tax_rule.rate and not self.tax_rule.pk:
|
||||
self.tax_rule = None
|
||||
|
||||
if self.tax_rate is None:
|
||||
self._calculate_tax()
|
||||
self.order.touch()
|
||||
@@ -1950,20 +2016,16 @@ class OrderPosition(AbstractPosition):
|
||||
self.item.id, self.variation.id if self.variation else 0, self.order_id
|
||||
)
|
||||
|
||||
def _calculate_tax(self):
|
||||
self.tax_rule = self.item.tax_rule
|
||||
def _calculate_tax(self, tax_rule=None):
|
||||
self.tax_rule = tax_rule or self.item.tax_rule
|
||||
try:
|
||||
ia = self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
if self.tax_rule:
|
||||
if self.tax_rule.tax_applicable(ia):
|
||||
tax = self.tax_rule.tax(self.price, base_price_is='gross')
|
||||
self.tax_rate = tax.rate
|
||||
self.tax_value = tax.tax
|
||||
else:
|
||||
self.tax_value = Decimal('0.00')
|
||||
self.tax_rate = Decimal('0.00')
|
||||
tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross')
|
||||
self.tax_rate = tax.rate
|
||||
self.tax_value = tax.tax
|
||||
else:
|
||||
self.tax_value = Decimal('0.00')
|
||||
self.tax_rate = Decimal('0.00')
|
||||
@@ -2021,7 +2083,9 @@ class OrderPosition(AbstractPosition):
|
||||
:param sender: Custom email sender.
|
||||
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
||||
"""
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
from pretix.base.services.mail import (
|
||||
SendMailException, mail, render_mail,
|
||||
)
|
||||
|
||||
if not self.attendee_email:
|
||||
return
|
||||
@@ -2102,6 +2166,10 @@ class CartPosition(AbstractPosition):
|
||||
includes_tax = models.BooleanField(
|
||||
default=True
|
||||
)
|
||||
override_tax_rate = models.DecimalField(
|
||||
max_digits=10, decimal_places=2,
|
||||
null=True, blank=True
|
||||
)
|
||||
is_bundled = models.BooleanField(default=False)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
@@ -2118,6 +2186,8 @@ class CartPosition(AbstractPosition):
|
||||
@property
|
||||
def tax_rate(self):
|
||||
if self.includes_tax:
|
||||
if self.override_tax_rate is not None:
|
||||
return self.override_tax_rate
|
||||
return self.item.tax(self.price, base_price_is='gross').rate
|
||||
else:
|
||||
return Decimal('0.00')
|
||||
@@ -2125,7 +2195,7 @@ class CartPosition(AbstractPosition):
|
||||
@property
|
||||
def tax_value(self):
|
||||
if self.includes_tax:
|
||||
return self.item.tax(self.price, base_price_is='gross').tax
|
||||
return self.item.tax(self.price, override_tax_rate=self.override_tax_rate, base_price_is='gross').tax
|
||||
else:
|
||||
return Decimal('0.00')
|
||||
|
||||
@@ -2141,8 +2211,8 @@ class InvoiceAddress(models.Model):
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
|
||||
country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
|
||||
country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
|
||||
countries=CachedCountries)
|
||||
country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
|
||||
countries=CachedCountries)
|
||||
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True)
|
||||
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'),
|
||||
help_text=_('Only for business customers within the EU.'))
|
||||
|
||||
@@ -113,7 +113,7 @@ class Organizer(LoggedModel):
|
||||
), tz)
|
||||
|
||||
def allow_delete(self):
|
||||
from . import Order, Invoice
|
||||
from . import Invoice, Order
|
||||
return (
|
||||
not Order.objects.filter(event__organizer=self).exists() and
|
||||
not Invoice.objects.filter(event__organizer=self).exists() and
|
||||
|
||||
@@ -42,7 +42,7 @@ class SeatingPlan(LoggedModel):
|
||||
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
|
||||
|
||||
Category = namedtuple('Categrory', 'name')
|
||||
RawSeat = namedtuple('Seat', 'name guid number row category zone sorting_rank row_label seat_label x y')
|
||||
RawSeat = namedtuple('Seat', 'guid number row category zone sorting_rank row_label seat_label x y')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -95,7 +95,6 @@ class SeatingPlan(LoggedModel):
|
||||
yield self.RawSeat(
|
||||
number=s['seat_number'],
|
||||
guid=s['seat_guid'],
|
||||
name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme?
|
||||
row=r['row_number'],
|
||||
row_label=row_label,
|
||||
seat_label=seat_label,
|
||||
@@ -125,7 +124,6 @@ class Seat(models.Model):
|
||||
"""
|
||||
event = models.ForeignKey(Event, related_name='seats', on_delete=models.CASCADE)
|
||||
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=190)
|
||||
zone_name = models.CharField(max_length=190, blank=True, default="")
|
||||
row_name = models.CharField(max_length=190, blank=True, default="")
|
||||
row_label = models.CharField(max_length=190, null=True)
|
||||
@@ -141,6 +139,10 @@ class Seat(models.Model):
|
||||
class Meta:
|
||||
ordering = ['sorting_rank', 'seat_guid']
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return str(self)
|
||||
|
||||
def __str__(self):
|
||||
parts = []
|
||||
if self.zone_name:
|
||||
@@ -157,13 +159,13 @@ class Seat(models.Model):
|
||||
parts.append(gettext('Seat {number}').format(number=self.seat_number))
|
||||
|
||||
if not parts:
|
||||
return self.name
|
||||
return self.seat_guid
|
||||
return ', '.join(parts)
|
||||
|
||||
@classmethod
|
||||
def annotated(cls, qs, event_id, subevent, ignore_voucher_id=None, minimal_distance=0,
|
||||
ignore_order_id=None, ignore_cart_id=None, distance_only_within_row=False):
|
||||
from . import Order, OrderPosition, Voucher, CartPosition
|
||||
from . import CartPosition, Order, OrderPosition, Voucher
|
||||
|
||||
vqs = Voucher.objects.filter(
|
||||
event_id=event_id,
|
||||
@@ -253,15 +255,18 @@ class Seat(models.Model):
|
||||
ignore_order_id=ignore_orderpos.order_id if ignore_orderpos else None,
|
||||
ignore_cart_id=(
|
||||
distance_ignore_cart_id or
|
||||
(ignore_cart.cart_id if ignore_cart else None)
|
||||
(ignore_cart.cart_id if ignore_cart and ignore_cart is not True else None)
|
||||
))
|
||||
q = Q(has_order=True) | Q(has_voucher=True)
|
||||
if ignore_cart is not True:
|
||||
q |= Q(has_cart=True)
|
||||
qs_closeby_taken = qs_annotated.annotate(
|
||||
distance=(
|
||||
Power(F('x') - Value(self.x), Value(2), output_field=models.FloatField()) +
|
||||
Power(F('y') - Value(self.y), Value(2), output_field=models.FloatField())
|
||||
)
|
||||
).exclude(pk=self.pk).filter(
|
||||
Q(has_order=True) | Q(has_cart=True) | Q(has_voucher=True),
|
||||
q,
|
||||
distance__lt=self.event.settings.seating_minimal_distance ** 2
|
||||
)
|
||||
if self.event.settings.seating_distance_within_row:
|
||||
|
||||
@@ -5,12 +5,12 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
from i18nfield.fields import I18nCharField
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.helpers.countries import FastCountryField
|
||||
|
||||
|
||||
class TaxedPrice:
|
||||
@@ -116,7 +116,7 @@ class TaxRule(LoggedModel):
|
||||
"ID. Only enable this option after consulting a tax counsel. No warranty given for correct tax "
|
||||
"calculation. USE AT YOUR OWN RISK.")
|
||||
)
|
||||
home_country = CountryField(
|
||||
home_country = FastCountryField(
|
||||
verbose_name=_('Merchant country'),
|
||||
blank=True,
|
||||
help_text=_('Your country of residence. This is the country the EU reverse charge rule will not apply in, '
|
||||
@@ -164,16 +164,41 @@ class TaxRule(LoggedModel):
|
||||
def has_custom_rules(self):
|
||||
return self.custom_rules and self.custom_rules != '[]'
|
||||
|
||||
def tax(self, base_price, base_price_is='auto', currency=None):
|
||||
def tax_rate_for(self, invoice_address):
|
||||
if not self._tax_applicable(invoice_address):
|
||||
return Decimal('0.00')
|
||||
if self.has_custom_rules:
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
|
||||
return Decimal(rule.get('rate'))
|
||||
return Decimal(self.rate)
|
||||
|
||||
def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, invoice_address=None,
|
||||
subtract_from_gross=Decimal('0.00'), gross_price_is_tax_rate: Decimal = None):
|
||||
from .event import Event
|
||||
try:
|
||||
currency = currency or self.event.currency
|
||||
except Event.DoesNotExist:
|
||||
pass
|
||||
if self.rate == Decimal('0.00'):
|
||||
|
||||
rate = Decimal(self.rate)
|
||||
if override_tax_rate is not None:
|
||||
rate = override_tax_rate
|
||||
elif invoice_address:
|
||||
adjust_rate = self.tax_rate_for(invoice_address)
|
||||
if adjust_rate == gross_price_is_tax_rate and base_price_is == 'gross':
|
||||
rate = adjust_rate
|
||||
elif adjust_rate != rate:
|
||||
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
||||
base_price = normal_price.net
|
||||
base_price_is = 'net'
|
||||
subtract_from_gross = Decimal('0.00')
|
||||
rate = adjust_rate
|
||||
|
||||
if rate == Decimal('0.00'):
|
||||
return TaxedPrice(
|
||||
net=base_price, gross=base_price, tax=Decimal('0.00'),
|
||||
rate=self.rate, name=self.name
|
||||
net=base_price - subtract_from_gross, gross=base_price - subtract_from_gross, tax=Decimal('0.00'),
|
||||
rate=rate, name=self.name
|
||||
)
|
||||
|
||||
if base_price_is == 'auto':
|
||||
@@ -183,19 +208,27 @@ class TaxRule(LoggedModel):
|
||||
base_price_is = 'net'
|
||||
|
||||
if base_price_is == 'gross':
|
||||
gross = base_price
|
||||
net = round_decimal(gross - (base_price * (1 - 100 / (100 + self.rate))),
|
||||
if base_price >= Decimal('0.00'):
|
||||
# For positive prices, make sure they don't go negative because of bundles
|
||||
gross = max(Decimal('0.00'), base_price - subtract_from_gross)
|
||||
else:
|
||||
# If the price is already negative, we don't really care any more
|
||||
gross = base_price - subtract_from_gross
|
||||
net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))),
|
||||
currency)
|
||||
elif base_price_is == 'net':
|
||||
net = base_price
|
||||
gross = round_decimal((net * (1 + self.rate / 100)),
|
||||
currency)
|
||||
gross = round_decimal((net * (1 + rate / 100)), currency)
|
||||
if subtract_from_gross:
|
||||
gross -= subtract_from_gross
|
||||
net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))),
|
||||
currency)
|
||||
else:
|
||||
raise ValueError('Unknown base price type: {}'.format(base_price_is))
|
||||
|
||||
return TaxedPrice(
|
||||
net=net, gross=gross, tax=gross - net,
|
||||
rate=self.rate, name=self.name
|
||||
rate=rate, name=self.name
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -243,7 +276,7 @@ class TaxRule(LoggedModel):
|
||||
|
||||
return False
|
||||
|
||||
def tax_applicable(self, invoice_address):
|
||||
def _tax_applicable(self, invoice_address):
|
||||
if self._custom_rules:
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
return rule.get('action', 'vat') == 'vat'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal, DecimalException
|
||||
|
||||
import pycountry
|
||||
@@ -12,11 +13,13 @@ from django.utils.translation import (
|
||||
)
|
||||
from django_countries import countries
|
||||
from django_countries.fields import Country
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.forms.questions import guess_country
|
||||
from pretix.base.models import (
|
||||
ItemVariation, OrderPosition, QuestionAnswer, QuestionOption, Seat,
|
||||
ItemVariation, OrderPosition, Question, QuestionAnswer, QuestionOption,
|
||||
Seat,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.settings import (
|
||||
@@ -417,7 +420,7 @@ class AttendeeStreet(ImportColumn):
|
||||
return _('Attendee address') + ': ' + _('Address')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.address = value or ''
|
||||
position.street = value or ''
|
||||
|
||||
|
||||
class AttendeeZip(ImportColumn):
|
||||
@@ -528,7 +531,7 @@ class Secret(ImportColumn):
|
||||
super().__init__(*args)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value and (value in self._cached or OrderPosition.all.filter(order__event=self.event, secret=value).exists()):
|
||||
if value and (value in self._cached or OrderPosition.all.filter(order__event__organizer=self.event.organizer, secret=value).exists()):
|
||||
raise ValidationError(
|
||||
_('You cannot assign a position secret that already exists.')
|
||||
)
|
||||
@@ -626,6 +629,22 @@ class Comment(ImportColumn):
|
||||
class QuestionColumn(ImportColumn):
|
||||
def __init__(self, event, q):
|
||||
self.q = q
|
||||
self.option_resolve_cache = defaultdict(set)
|
||||
|
||||
for opt in q.options.all():
|
||||
self.option_resolve_cache[str(opt.id)].add(opt)
|
||||
self.option_resolve_cache[opt.identifier].add(opt)
|
||||
|
||||
if isinstance(opt.answer, LazyI18nString):
|
||||
|
||||
if isinstance(opt.answer.data, dict):
|
||||
for v in opt.answer.data.values():
|
||||
self.option_resolve_cache[v.strip()].add(opt)
|
||||
else:
|
||||
self.option_resolve_cache[opt.answer.strip()].add(opt)
|
||||
|
||||
else:
|
||||
self.option_resolve_cache[opt.answer.strip()].add(opt)
|
||||
super().__init__(event)
|
||||
|
||||
@property
|
||||
@@ -638,7 +657,23 @@ class QuestionColumn(ImportColumn):
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
return self.q.clean_answer(value)
|
||||
if self.q.type == Question.TYPE_CHOICE:
|
||||
if value not in self.option_resolve_cache:
|
||||
raise ValidationError(_('Invalid option selected.'))
|
||||
if len(self.option_resolve_cache[value]) > 1:
|
||||
raise ValidationError(_('Ambigous option selected.'))
|
||||
return list(self.option_resolve_cache[value])[0]
|
||||
|
||||
elif self.q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
values = value.split(',')
|
||||
if any(v.strip() not in self.option_resolve_cache for v in values):
|
||||
raise ValidationError(_('Invalid option selected.'))
|
||||
if any(len(self.option_resolve_cache[v.strip()]) > 1 for v in values):
|
||||
raise ValidationError(_('Ambigous option selected.'))
|
||||
return [list(self.option_resolve_cache[v.strip()])[0] for v in values]
|
||||
|
||||
else:
|
||||
return self.q.clean_answer(value)
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
if value:
|
||||
@@ -702,7 +737,7 @@ def get_all_columns(event):
|
||||
SeatColumn(event),
|
||||
Comment(event)
|
||||
]
|
||||
for q in event.questions.exclude(type='F'):
|
||||
for q in event.questions.prefetch_related('options').exclude(type=Question.TYPE_FILE):
|
||||
default.append(QuestionColumn(event, q))
|
||||
|
||||
for recv, resp in order_import_columns.send(sender=event):
|
||||
|
||||
@@ -49,6 +49,7 @@ class PaymentProviderForm(Form):
|
||||
val = cleaned_data.get(k)
|
||||
if v._required and not val:
|
||||
self.add_error(k, _('This field is required.'))
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class BasePaymentProvider:
|
||||
@@ -167,13 +168,23 @@ class BasePaymentProvider:
|
||||
@property
|
||||
def abort_pending_allowed(self) -> bool:
|
||||
"""
|
||||
Whether or not a user can abort a payment in pending start to switch to another
|
||||
Whether or not a user can abort a payment in pending state to switch to another
|
||||
payment method. This returns ``False`` by default which is no guarantee that
|
||||
aborting a pending payment can never happen, it just hides the frontend button
|
||||
to avoid users accidentally committing double payments.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def requires_invoice_immediately(self):
|
||||
"""
|
||||
Return whether this payment method requires an invoice to exist for an order, even though the event
|
||||
is configured to only create invoices for paid orders.
|
||||
By default this is False, but it might be overwritten for e.g. bank transfer.
|
||||
`execute_payment` is called after the invoice is created.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def settings_form_fields(self) -> dict:
|
||||
"""
|
||||
@@ -306,7 +317,7 @@ class BasePaymentProvider:
|
||||
initial=['web'],
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
help_text=_(
|
||||
'Only allow the usage of this payment provider in the following sales channels'),
|
||||
'Only allow the usage of this payment provider in the selected sales channels.'),
|
||||
)),
|
||||
('_hidden',
|
||||
forms.BooleanField(
|
||||
@@ -374,6 +385,8 @@ class BasePaymentProvider:
|
||||
"""
|
||||
return {}
|
||||
|
||||
payment_form_class = PaymentProviderForm
|
||||
|
||||
def payment_form(self, request: HttpRequest) -> Form:
|
||||
"""
|
||||
This is called by the default implementation of :py:meth:`payment_form_render`
|
||||
@@ -386,7 +399,7 @@ class BasePaymentProvider:
|
||||
``PaymentProviderForm`` (from this module) that handles some nasty issues about
|
||||
required fields for you.
|
||||
"""
|
||||
form = PaymentProviderForm(
|
||||
form = self.payment_form_class(
|
||||
data=(request.POST if request.method == 'POST' and request.POST.get("payment") == self.identifier else None),
|
||||
prefix='payment_%s' % self.identifier,
|
||||
initial={
|
||||
@@ -767,7 +780,7 @@ class BasePaymentProvider:
|
||||
|
||||
def matching_id(self, payment: OrderPayment):
|
||||
"""
|
||||
Will be called to get an ID for a matching this payment when comparing pretix records with records of an external
|
||||
Will be called to get an ID for matching this payment when comparing pretix records with records of an external
|
||||
source. This should return the main transaction ID for your API.
|
||||
|
||||
:param payment: The payment in question.
|
||||
@@ -902,7 +915,7 @@ class ManualPayment(BasePaymentProvider):
|
||||
label=_('Payment process description in order confirmation emails'),
|
||||
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
|
||||
'mails. It should instruct the user on how to proceed with the payment. You can use '
|
||||
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
|
||||
'the placeholders {order}, {total}, {currency} and {total_with_currency}.'),
|
||||
widget=I18nTextarea,
|
||||
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
|
||||
)),
|
||||
@@ -910,7 +923,7 @@ class ManualPayment(BasePaymentProvider):
|
||||
label=_('Payment process description for pending orders'),
|
||||
help_text=_('This text will be shown on the order confirmation page for pending orders. '
|
||||
'It should instruct the user on how to proceed with the payment. You can use '
|
||||
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
|
||||
'the placeholders {order}, {total}, {currency} and {total_with_currency}.'),
|
||||
widget=I18nTextarea,
|
||||
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
|
||||
)),
|
||||
@@ -1062,7 +1075,10 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
|
||||
def api_payment_details(self, payment: OrderPayment):
|
||||
from .models import GiftCard
|
||||
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
|
||||
try:
|
||||
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
|
||||
except GiftCard.DoesNotExist:
|
||||
return {}
|
||||
return {
|
||||
'gift_card': {
|
||||
'id': gc.pk,
|
||||
@@ -1118,7 +1134,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
cart['raw']
|
||||
)
|
||||
total += sum([f.value for f in fees])
|
||||
remainder = total - gc.value
|
||||
remainder = total
|
||||
if remainder > Decimal('0.00'):
|
||||
del cs['payment']
|
||||
messages.success(request, _("Your gift card has been applied, but {} still need to be paid. Please select a payment method.").format(
|
||||
@@ -1199,7 +1215,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
if not gc.accepted_by(self.event.organizer): # noqa - just a safeguard
|
||||
raise PaymentException(_("This gift card is not accepted by this event organizer."))
|
||||
if payment.amount > gc.value: # noqa - just a safeguard
|
||||
raise PaymentException(_("This gift card was used in the meantime. Please try again"))
|
||||
raise PaymentException(_("This gift card was used in the meantime. Please try again."))
|
||||
if gc.expires and gc.expires < now(): # noqa - just a safeguard
|
||||
messages.error(request, _("This gift card is no longer valid."))
|
||||
return
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user