forked from CGM_Public/pretix_original
Compare commits
512 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e9d09e1f | ||
|
|
93947cace0 | ||
|
|
d4eac76a8d | ||
|
|
8889607d1c | ||
|
|
5e9e00acec | ||
|
|
0e89d4c0f7 | ||
|
|
8b3ce69425 | ||
|
|
b20d1e8373 | ||
|
|
c278687487 | ||
|
|
0c45e73456 | ||
|
|
104f84b7a8 | ||
|
|
ac4ecfbe69 | ||
|
|
61c6cd2937 | ||
|
|
38066ca5ab | ||
|
|
373ab29701 | ||
|
|
7302bba602 | ||
|
|
5096121ac7 | ||
|
|
ca4c21a843 | ||
|
|
407ecdf6c5 | ||
|
|
2faeee8e9c | ||
|
|
e1bbf7139f | ||
|
|
64fc38a06e | ||
|
|
6bcf884b7a | ||
|
|
d319293da8 | ||
|
|
832c58d288 | ||
|
|
c251e0e7d3 | ||
|
|
27437e065a | ||
|
|
86534aa7cc | ||
|
|
379a2140c8 | ||
|
|
67059fe323 | ||
|
|
8ffc96bf31 | ||
|
|
58b688628e | ||
|
|
3f7348717b | ||
|
|
90c8e0c172 | ||
|
|
d35ad345d7 | ||
|
|
21634369a8 | ||
|
|
a2b075c0d7 | ||
|
|
0617abe6e3 | ||
|
|
040466353c | ||
|
|
46b7e9467b | ||
|
|
283ff3b5e5 | ||
|
|
b0bb22ea38 | ||
|
|
334ee98318 | ||
|
|
c4d342029b | ||
|
|
bc86f9c059 | ||
|
|
51107fe4fd | ||
|
|
3d65c2fd51 | ||
|
|
9b394b3833 | ||
|
|
d5747084ec | ||
|
|
777772b89e | ||
|
|
c202286470 | ||
|
|
0c1738b9bb | ||
|
|
af607083cb | ||
|
|
def7918b29 | ||
|
|
0933fc848d | ||
|
|
166f8b8a2a | ||
|
|
70fcba96a5 | ||
|
|
2d2d62045a | ||
|
|
3988f1e2f6 | ||
|
|
d3ecb92108 | ||
|
|
b3debdfb55 | ||
|
|
abb770a8e7 | ||
|
|
72a2d0da35 | ||
|
|
937cec53f7 | ||
|
|
6e4af5da64 | ||
|
|
7ed35e06ba | ||
|
|
55841ea660 | ||
|
|
78544cdb30 | ||
|
|
37183aced7 | ||
|
|
a7d3cb134c | ||
|
|
da8f7f163f | ||
|
|
89d612beed | ||
|
|
f23de7e2c0 | ||
|
|
d073007fd7 | ||
|
|
d9d1c83218 | ||
|
|
ae9b8bafb8 | ||
|
|
cbf5c2ec1d | ||
|
|
17392f3ef4 | ||
|
|
bf36ad009f | ||
|
|
ca9e4823e2 | ||
|
|
d505422e0f | ||
|
|
33c43ce482 | ||
|
|
f273cf4960 | ||
|
|
afdf09eeb4 | ||
|
|
01e5872f61 | ||
|
|
14cc31c810 | ||
|
|
2972129547 | ||
|
|
ec4227651a | ||
|
|
77950de588 | ||
|
|
187576eee5 | ||
|
|
0e513a0985 | ||
|
|
1cde728ffe | ||
|
|
76893caffc | ||
|
|
a539999c04 | ||
|
|
b9c570b3d8 | ||
|
|
48b399424a | ||
|
|
1c73f000a9 | ||
|
|
d0721165c1 | ||
|
|
bed0a0ceeb | ||
|
|
b53ee1dc1d | ||
|
|
41b56c00e5 | ||
|
|
cb17febf7c | ||
|
|
07d42a4d77 | ||
|
|
e3ebf887a4 | ||
|
|
0440187e59 | ||
|
|
dfcda0fa2c | ||
|
|
560c0a8729 | ||
|
|
bc80b60b04 | ||
|
|
08bf3648ea | ||
|
|
f8ee7acad6 | ||
|
|
10c86869ea | ||
|
|
9034a98df9 | ||
|
|
a7142fdf55 | ||
|
|
ee97c46aec | ||
|
|
7063f32f24 | ||
|
|
2ec926b7c7 | ||
|
|
834b5a26a5 | ||
|
|
90f08d0aca | ||
|
|
d5c2637198 | ||
|
|
f517ba51bd | ||
|
|
d738198ec5 | ||
|
|
b1ce58d06c | ||
|
|
b26ef74128 | ||
|
|
4f8c8ea917 | ||
|
|
0803b049af | ||
|
|
97f3fbdb80 | ||
|
|
434b6e4729 | ||
|
|
f56bceb55f | ||
|
|
2aa246b3d5 | ||
|
|
f77b551aa6 | ||
|
|
c9415cba2b | ||
|
|
4dae224d73 | ||
|
|
13cc57e98b | ||
|
|
6f980b82ac | ||
|
|
f32c581a9e | ||
|
|
fcadfffb92 | ||
|
|
9e43459879 | ||
|
|
87424c25de | ||
|
|
acdf7d62b5 | ||
|
|
944138f7a9 | ||
|
|
5da2eab1fb | ||
|
|
d680937a6c | ||
|
|
f35c2544b6 | ||
|
|
0285cd12f7 | ||
|
|
03cacace57 | ||
|
|
6ed016e49e | ||
|
|
da8da01614 | ||
|
|
9a2ea6699a | ||
|
|
51a8bac9e6 | ||
|
|
303ed07504 | ||
|
|
c7627f631f | ||
|
|
604c31c6e2 | ||
|
|
c3da6731a1 | ||
|
|
6e556ab09b | ||
|
|
16622883f6 | ||
|
|
cce4379d3e | ||
|
|
5af99f4f1a | ||
|
|
9ed49888b8 | ||
|
|
5bfb00db73 | ||
|
|
a031d72ca9 | ||
|
|
15a190cdf3 | ||
|
|
d181375479 | ||
|
|
d8a57b0baa | ||
|
|
d482bc9de0 | ||
|
|
5c030796d7 | ||
|
|
f6eb3bfb80 | ||
|
|
3703fbcacf | ||
|
|
cdea6eb55e | ||
|
|
bf1e9d47d0 | ||
|
|
350df2a3cc | ||
|
|
bc6915b251 | ||
|
|
f9c7eeff9a | ||
|
|
247bcf0a20 | ||
|
|
455c961fc7 | ||
|
|
9052d4a7a9 | ||
|
|
589401e8d2 | ||
|
|
0c366a8473 | ||
|
|
c9ddbd0e88 | ||
|
|
31bf0c24f1 | ||
|
|
c74386346b | ||
|
|
725e1f019e | ||
|
|
06eddb2c6d | ||
|
|
80b5750756 | ||
|
|
f37d265534 | ||
|
|
7c4a1e5fb8 | ||
|
|
9a045c76ec | ||
|
|
447b36fdd3 | ||
|
|
5dbd984178 | ||
|
|
95f96f8321 | ||
|
|
3933032778 | ||
|
|
d0b18d9f64 | ||
|
|
71de71ed37 | ||
|
|
3438d079d5 | ||
|
|
e7730333c2 | ||
|
|
e8b9f0a3ae | ||
|
|
77ebd18404 | ||
|
|
2d48198c83 | ||
|
|
d103b0bb84 | ||
|
|
01411b84e4 | ||
|
|
b7e154d8c9 | ||
|
|
f39ac96322 | ||
|
|
74db808978 | ||
|
|
ab72b93706 | ||
|
|
af5aece639 | ||
|
|
228ab15900 | ||
|
|
66164d8202 | ||
|
|
d5ac155914 | ||
|
|
75a966529e | ||
|
|
28a6a6185d | ||
|
|
07cdaa9ca9 | ||
|
|
1c6935ebd9 | ||
|
|
60c1ea8aad | ||
|
|
0b8798a65c | ||
|
|
a8836cbeec | ||
|
|
336a34b10b | ||
|
|
c5862cc0a0 | ||
|
|
89cdcd3781 | ||
|
|
2837cac554 | ||
|
|
3b54556739 | ||
|
|
4d6d6ff737 | ||
|
|
ffee31e415 | ||
|
|
8abfbba9d0 | ||
|
|
588955901c | ||
|
|
4b7bf2f27f | ||
|
|
664957e886 | ||
|
|
f15a6d39c3 | ||
|
|
3fd80a9a46 | ||
|
|
2fd2716303 | ||
|
|
37315fc380 | ||
|
|
f96fc0744e | ||
|
|
5bb7883020 | ||
|
|
3f95434922 | ||
|
|
08da5a8b91 | ||
|
|
97dc4421ea | ||
|
|
26ca2ff006 | ||
|
|
980c359f57 | ||
|
|
ff1198dec6 | ||
|
|
7275de94af | ||
|
|
ed46f41f8c | ||
|
|
1078e38890 | ||
|
|
2e9bbff308 | ||
|
|
13a48701fa | ||
|
|
ddc9c850c0 | ||
|
|
fa0dae6ed6 | ||
|
|
da6176a51e | ||
|
|
4ef6659551 | ||
|
|
82624a1dc0 | ||
|
|
b50add260a | ||
|
|
f72f97d366 | ||
|
|
ad46e9e541 | ||
|
|
343dbc00be | ||
|
|
3cb94f702d | ||
|
|
ddeae224fb | ||
|
|
3c57895101 | ||
|
|
687c85eb58 | ||
|
|
90ffdbdfa3 | ||
|
|
654be0db34 | ||
|
|
82e3359b40 | ||
|
|
01a6861453 | ||
|
|
7f6cdd6241 | ||
|
|
aad1fda31f | ||
|
|
ad462921f0 | ||
|
|
dc433f6420 | ||
|
|
2d8b3d1c79 | ||
|
|
eb85fa956e | ||
|
|
215514fca7 | ||
|
|
3fe2dfe810 | ||
|
|
041d05eb66 | ||
|
|
d05530ddfc | ||
|
|
734e77d1a3 | ||
|
|
633061e203 | ||
|
|
e11ee4a427 | ||
|
|
1edcd47703 | ||
|
|
cf4b2544f2 | ||
|
|
04c3cffd43 | ||
|
|
483d41c7a6 | ||
|
|
b0c4c88d01 | ||
|
|
518298f71c | ||
|
|
62c2e7765b | ||
|
|
2bb2a40509 | ||
|
|
49828186b0 | ||
|
|
c07a6cb4aa | ||
|
|
67ad9a0dcb | ||
|
|
d267dfc682 | ||
|
|
eed220f14a | ||
|
|
85289fe0d1 | ||
|
|
6293ad34d4 | ||
|
|
0dc4f61cf0 | ||
|
|
6849e682d7 | ||
|
|
6b725a9db9 | ||
|
|
989ebbb444 | ||
|
|
0a6efc1e0f | ||
|
|
d577a0d286 | ||
|
|
6b9b379ce2 | ||
|
|
13234b6fd5 | ||
|
|
2fa0067663 | ||
|
|
4e37fa5778 | ||
|
|
bfb74448b1 | ||
|
|
a255082b07 | ||
|
|
14df35bd90 | ||
|
|
bd0ba7baa5 | ||
|
|
9aa220b95b | ||
|
|
3ed4be63fe | ||
|
|
23f4b0b62f | ||
|
|
4b9acb64da | ||
|
|
ebba0ee0cb | ||
|
|
335ce48d7e | ||
|
|
d9a0c8c523 | ||
|
|
a297bd1944 | ||
|
|
953ea26984 | ||
|
|
e4f80f7660 | ||
|
|
128a185957 | ||
|
|
0bdd14b47a | ||
|
|
3b84b181ad | ||
|
|
c9b0626324 | ||
|
|
dc9a82cade | ||
|
|
8266733e34 | ||
|
|
246987955b | ||
|
|
b93e7fcb60 | ||
|
|
b1cebdbd99 | ||
|
|
d04047abd5 | ||
|
|
efca46945a | ||
|
|
0f9755e36f | ||
|
|
478d8e4116 | ||
|
|
81693e042c | ||
|
|
47b7d7b36c | ||
|
|
ba15c34ce1 | ||
|
|
94f2ad9325 | ||
|
|
d8070ba8a3 | ||
|
|
b1019672b0 | ||
|
|
631307a4d5 | ||
|
|
180a26ee1d | ||
|
|
7eab1982fe | ||
|
|
ca59237ebf | ||
|
|
cc92210dc2 | ||
|
|
6602afdd6c | ||
|
|
c7a04bc08a | ||
|
|
2cc5b7f4e8 | ||
|
|
453f16af03 | ||
|
|
0f3398ae13 | ||
|
|
f1b65c8695 | ||
|
|
2c4c89c8c2 | ||
|
|
4042b603b7 | ||
|
|
63b0288383 | ||
|
|
7c01fee70b | ||
|
|
8127c32ef5 | ||
|
|
563decdfba | ||
|
|
a205b01d70 | ||
|
|
b4290384e1 | ||
|
|
0f76779fb1 | ||
|
|
f34c528cba | ||
|
|
cf01e04101 | ||
|
|
a3a63def55 | ||
|
|
a3489eea04 | ||
|
|
c6cb98c30a | ||
|
|
332c58c82f | ||
|
|
beb0ded6dc | ||
|
|
b49b2035bd | ||
|
|
106c8d373d | ||
|
|
aee44a3284 | ||
|
|
d4c1fcf838 | ||
|
|
832f57c9d7 | ||
|
|
ac2a9b207d | ||
|
|
f1e5d60a14 | ||
|
|
7b1a1dc754 | ||
|
|
c93f804992 | ||
|
|
1cba4b1d45 | ||
|
|
22369a5559 | ||
|
|
a8223ad354 | ||
|
|
c9d3cf7cac | ||
|
|
bbdbc94f6e | ||
|
|
5c8d9c4dca | ||
|
|
546ff6e42f | ||
|
|
7b7d45ce2e | ||
|
|
be3ca7c561 | ||
|
|
abdb6e2d52 | ||
|
|
138ddcdcd7 | ||
|
|
8ffc6550da | ||
|
|
0734715bab | ||
|
|
7528bfb10b | ||
|
|
2798fb3468 | ||
|
|
4e6f4716ec | ||
|
|
e523a4e610 | ||
|
|
31cec76809 | ||
|
|
fdfd9f9275 | ||
|
|
b658c73c19 | ||
|
|
ebd3e6f31a | ||
|
|
ccec114653 | ||
|
|
f0716dc482 | ||
|
|
513778b2c4 | ||
|
|
742e403ae2 | ||
|
|
09a9d610f8 | ||
|
|
b9534f23f5 | ||
|
|
b053f61001 | ||
|
|
21042f2111 | ||
|
|
e953474138 | ||
|
|
0d438ad07c | ||
|
|
e285b7cff0 | ||
|
|
2bb2f30e66 | ||
|
|
9a8d23f582 | ||
|
|
f37d12e056 | ||
|
|
334ffc0be7 | ||
|
|
03f0da4ee6 | ||
|
|
fbbd6eebc0 | ||
|
|
584ced87db | ||
|
|
901953d988 | ||
|
|
8c34a47138 | ||
|
|
0fe3db634c | ||
|
|
d8d838fc4f | ||
|
|
9b94a1b3b2 | ||
|
|
479abc1a65 | ||
|
|
1a17ba13ca | ||
|
|
371c42b738 | ||
|
|
ed85394845 | ||
|
|
a9a684a456 | ||
|
|
d7d7792a4a | ||
|
|
c09587f5d3 | ||
|
|
23f719381c | ||
|
|
d74d39d6e9 | ||
|
|
5f2cf8d3ef | ||
|
|
1843799345 | ||
|
|
bd838b3b7c | ||
|
|
c2d03f5e6b | ||
|
|
74e8e73877 | ||
|
|
8830dc8f78 | ||
|
|
ac877a7c0d | ||
|
|
0a442e712b | ||
|
|
4477f8001e | ||
|
|
152b94428f | ||
|
|
5390b0b191 | ||
|
|
97de8cea08 | ||
|
|
cd465c1aad | ||
|
|
449dea41a8 | ||
|
|
0b1a6e4745 | ||
|
|
e49061e28c | ||
|
|
18cb29b425 | ||
|
|
994ff23719 | ||
|
|
15d077df6e | ||
|
|
b490aa7f5d | ||
|
|
ca6b3badde | ||
|
|
1f200271af | ||
|
|
894a60d016 | ||
|
|
4a2219134b | ||
|
|
7d38fc5c03 | ||
|
|
ef5de187b9 | ||
|
|
a1c424266b | ||
|
|
557b4b7b6f | ||
|
|
98be21253d | ||
|
|
e5a04ada94 | ||
|
|
9b8b3090e6 | ||
|
|
e622c3948d | ||
|
|
94be46ffdb | ||
|
|
7039374588 | ||
|
|
0a5347c08b | ||
|
|
87f3318431 | ||
|
|
2557a8e4ec | ||
|
|
aff7094cb0 | ||
|
|
5a29b4bf70 | ||
|
|
e618183b49 | ||
|
|
a18236b12d | ||
|
|
b5da4e89a6 | ||
|
|
1da2737427 | ||
|
|
032fdadc3c | ||
|
|
8ae3ff3fe6 | ||
|
|
b8669503fa | ||
|
|
863165caaa | ||
|
|
b885f30789 | ||
|
|
461b62bd51 | ||
|
|
23776db3b6 | ||
|
|
19e91a6c7c | ||
|
|
6f40325d3f | ||
|
|
1987bff4b1 | ||
|
|
5aa0d55d47 | ||
|
|
a28196e930 | ||
|
|
c55387819d | ||
|
|
c8cc527aee | ||
|
|
a39b207ad5 | ||
|
|
ea63b50f2e | ||
|
|
b101251aa4 | ||
|
|
c9ba72ebc5 | ||
|
|
4a1c3088a9 | ||
|
|
a480ca1142 | ||
|
|
a928fbfafe | ||
|
|
3bf3ff1ee2 | ||
|
|
9647cc6cf2 | ||
|
|
df2d8925ed | ||
|
|
7a945daefc | ||
|
|
409e77cf2f | ||
|
|
552f99a63b | ||
|
|
0842311451 | ||
|
|
4d4b498636 | ||
|
|
d08cc12240 | ||
|
|
237442872e | ||
|
|
16983826fb | ||
|
|
e60ff6b777 | ||
|
|
3a0ef3760c | ||
|
|
bc0bc78219 | ||
|
|
d3137505a1 | ||
|
|
a2acd336eb | ||
|
|
6e4750336b | ||
|
|
ddefeeaf02 | ||
|
|
250e0a930d | ||
|
|
51c6d60760 | ||
|
|
db513b21f8 | ||
|
|
ab336678ce | ||
|
|
3eea4d6945 | ||
|
|
d091d3fd17 | ||
|
|
fc71f484ad | ||
|
|
bd772bf900 | ||
|
|
14db654681 | ||
|
|
a85b96ea89 | ||
|
|
c2b5e876bc |
@@ -11,7 +11,6 @@ fi
|
||||
|
||||
if [ "$PRETIX_CONFIG_FILE" == "tests/travis_postgres.cfg" ]; then
|
||||
psql -c 'create database travis_ci_test;' -U postgres
|
||||
pip3 install -Ur src/requirements/postgres.txt
|
||||
fi
|
||||
|
||||
if [ "$1" == "style" ]; then
|
||||
@@ -39,11 +38,11 @@ if [ "$1" == "translation-spelling" ]; then
|
||||
potypo
|
||||
fi
|
||||
if [ "$1" == "tests" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt pytest-xdist
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
py.test --reruns 5 -n 2 tests
|
||||
py.test --reruns 5 -n 3 tests
|
||||
fi
|
||||
if [ "$1" == "tests-cov" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
|
||||
10
.travis.yml
10
.travis.yml
@@ -1,7 +1,7 @@
|
||||
language: python
|
||||
sudo: false
|
||||
install:
|
||||
- pip install -U pip wheel setuptools==28.6.1
|
||||
- pip install -U pip wheel setuptools
|
||||
script:
|
||||
- bash .travis.sh $JOB
|
||||
cache:
|
||||
@@ -18,12 +18,12 @@ matrix:
|
||||
env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.6
|
||||
env: JOB=style
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
|
||||
- python: 3.6
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.6
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.6
|
||||
env: JOB=plugins
|
||||
- python: 3.6
|
||||
@@ -32,11 +32,15 @@ matrix:
|
||||
env: JOB=translation-spelling
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
mariadb: '10.3'
|
||||
apt:
|
||||
packages:
|
||||
- enchant
|
||||
- myspell-de-de
|
||||
- aspell-en
|
||||
- sqlite3
|
||||
sources:
|
||||
- travis-ci/sqlite3
|
||||
branches:
|
||||
except:
|
||||
- /^weblate-.*/
|
||||
|
||||
43
Dockerfile
43
Dockerfile
@@ -1,10 +1,26 @@
|
||||
FROM python:3.6
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y git libxml2-dev libxslt1-dev python-dev python-virtualenv locales \
|
||||
libffi-dev build-essential python3-dev zlib1g-dev libssl-dev gettext libpq-dev \
|
||||
default-libmysqlclient-dev libmemcached-dev libjpeg-dev supervisor nginx sudo \
|
||||
--no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
default-libmysqlclient-dev \
|
||||
gettext \
|
||||
git \
|
||||
libffi-dev \
|
||||
libjpeg-dev \
|
||||
libmemcached-dev \
|
||||
libpq-dev \
|
||||
libssl-dev \
|
||||
libxml2-dev \
|
||||
libxslt1-dev \
|
||||
locales \
|
||||
nginx \
|
||||
python-dev \
|
||||
python-virtualenv \
|
||||
python3-dev \
|
||||
sudo \
|
||||
supervisor \
|
||||
zlib1g-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
dpkg-reconfigure locales && \
|
||||
@@ -19,6 +35,22 @@ RUN apt-get update && \
|
||||
ENV LC_ALL=C.UTF-8 \
|
||||
DJANGO_SETTINGS_MODULE=production_settings
|
||||
|
||||
# To copy only the requirements files needed to install from PIP
|
||||
COPY src/requirements /pretix/src/requirements
|
||||
COPY src/requirements.txt /pretix/src
|
||||
RUN pip3 install -U \
|
||||
pip \
|
||||
setuptools \
|
||||
wheel && \
|
||||
cd /pretix/src && \
|
||||
pip3 install \
|
||||
-r requirements.txt \
|
||||
-r requirements/memcached.txt \
|
||||
-r requirements/mysql.txt \
|
||||
-r requirements/redis.txt \
|
||||
gunicorn && \
|
||||
rm -rf ~/.cache/pip
|
||||
|
||||
COPY deployment/docker/pretix.bash /usr/local/bin/pretix
|
||||
COPY deployment/docker/supervisord.conf /etc/supervisord.conf
|
||||
COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
|
||||
@@ -27,11 +59,8 @@ COPY src /pretix/src
|
||||
|
||||
RUN chmod +x /usr/local/bin/pretix && \
|
||||
rm /etc/nginx/sites-enabled/default && \
|
||||
pip3 install -U pip wheel setuptools && \
|
||||
cd /pretix/src && \
|
||||
rm -f pretix.cfg && \
|
||||
pip3 install -r requirements.txt -r requirements/mysql.txt -r requirements/postgres.txt \
|
||||
-r requirements/memcached.txt -r requirements/redis.txt gunicorn && \
|
||||
mkdir -p data && \
|
||||
chown -R pretixuser:pretixuser /pretix /data data && \
|
||||
sudo -u pretixuser make production
|
||||
|
||||
@@ -125,6 +125,23 @@ Example::
|
||||
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
|
||||
turns on some optimizations/special case handlers. Default: ``False``
|
||||
|
||||
Database replica settings
|
||||
-------------------------
|
||||
|
||||
If you use a replicated database setup, pretix expects that the default database connection always points to the primary database node.
|
||||
Routing read queries to a replica on database layer is **strongly** discouraged since this can lead to inaccurate such as more tickets
|
||||
being sold than are actually available.
|
||||
|
||||
However, pretix can still make use of a database replica to keep some expensive queries with that can tolerate some latency from your
|
||||
primary database, such as backend search queries. The ``replica`` configuration section can have the same settings as the ``database``
|
||||
section (except for the ``backend`` setting) and will default back to the ``database`` settings for all values that are not given. This
|
||||
way, you just need to specify the settings that are different for the replica.
|
||||
|
||||
Example::
|
||||
|
||||
[replica]
|
||||
host=192.168.0.2
|
||||
|
||||
URLs
|
||||
----
|
||||
|
||||
@@ -295,5 +312,13 @@ various places like order codes, secrets in the ticket QR codes, etc. Example::
|
||||
; Voucher code needs to be < 255 characters, default is 16
|
||||
voucher_code=16
|
||||
|
||||
External tools
|
||||
--------------
|
||||
|
||||
pretix can make use of some external tools if they are installed. Currently, they are all optional. Example::
|
||||
|
||||
[tools]
|
||||
pdftk=/usr/bin/pdftk
|
||||
|
||||
.. _Python documentation: https://docs.python.org/3/library/configparser.html?highlight=configparser#supported-ini-file-structure
|
||||
.. _Celery documentation: http://docs.celeryproject.org/en/latest/userguide/configuration.html
|
||||
|
||||
37
doc/admin/installation/dev_version.rst
Normal file
37
doc/admin/installation/dev_version.rst
Normal file
@@ -0,0 +1,37 @@
|
||||
.. highlight:: none
|
||||
|
||||
Installing a development version
|
||||
================================
|
||||
|
||||
If you want to use a feature of pretix that is not yet contained in the last monthly release, you can also
|
||||
install a development version with pretix.
|
||||
|
||||
.. warning:: When in production, we strongly recommend only installing released versions. Development versions might
|
||||
be broken, incompatible to plugins, or in rare cases incompatible to upgrade later on.
|
||||
|
||||
|
||||
Manual installation
|
||||
-------------------
|
||||
|
||||
You can use ``pip`` to update pretix directly to the development branch. Then, upgrade as usual::
|
||||
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install -U "git+https://github.com/pretix/pretix.git#egg=pretix&subdirectory=src"
|
||||
(venv)$ python -m pretix migrate
|
||||
(venv)$ python -m pretix rebuild
|
||||
(venv)$ python -m pretix updatestyles
|
||||
# systemctl restart pretix-web pretix-worker
|
||||
|
||||
Docker installation
|
||||
-------------------
|
||||
|
||||
To use the latest development version with Docker, first pull it from Docker Hub::
|
||||
|
||||
$ docker pull pretix/standalone:latest
|
||||
|
||||
|
||||
Then change your ``/etc/systemd/system/pretix.service`` file to use the ``:latest`` tag instead of ``:stable`` as well
|
||||
and upgrade as usual::
|
||||
|
||||
$ systemctl restart pretix.service
|
||||
$ docker exec -it pretix.service pretix upgrade
|
||||
@@ -26,7 +26,7 @@ installation guides):
|
||||
* `Docker`_
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `MySQL`_ or `PostgreSQL`_ database server
|
||||
* A `PostgreSQL`_, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `redis`_ server
|
||||
|
||||
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
||||
@@ -36,6 +36,9 @@ Linux and firewalls, we recommend that you start with `ufw`_.
|
||||
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
|
||||
installations except for evaluation purposes.
|
||||
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
On this guide
|
||||
-------------
|
||||
|
||||
@@ -58,7 +61,7 @@ Next, we need a database and a database user. We can create these with any kind
|
||||
our database's shell, e.g. for MySQL::
|
||||
|
||||
$ mysql -u root -p
|
||||
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;
|
||||
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
mysql> GRANT ALL PRIVILEGES ON pretix.* TO pretix@'localhost' IDENTIFIED BY '*********';
|
||||
mysql> FLUSH PRIVILEGES;
|
||||
|
||||
|
||||
84
doc/admin/installation/enterprise.rst
Normal file
84
doc/admin/installation/enterprise.rst
Normal file
@@ -0,0 +1,84 @@
|
||||
.. highlight:: none
|
||||
|
||||
Installing pretix Enterprise plugins
|
||||
====================================
|
||||
|
||||
If you want to use a feature of pretix that is part of our commercial offering pretix Enterprise, you need to follow
|
||||
some extra steps. Installation works similar to normal pretix plugins, but involves a few extra steps.
|
||||
|
||||
Buying the license
|
||||
------------------
|
||||
|
||||
To obtain a license, please get in touch at sales@pretix.eu. Please let us know how many tickets you roughly intend
|
||||
to sell per year and how many servers you want to use the plugin on. We recommend having a look at our `price list`_
|
||||
first.
|
||||
|
||||
|
||||
Manual installation
|
||||
-------------------
|
||||
|
||||
First, generate an SSH key for the system user that you install pretix as. In our tutorial, that would be the user
|
||||
``pretix``. Choose an empty passphrase::
|
||||
|
||||
# su pretix
|
||||
$ ssh-keygen
|
||||
Generating public/private rsa key pair.
|
||||
Enter file in which to save the key (/var/pretix/.ssh/id_rsa):
|
||||
Enter passphrase (empty for no passphrase):
|
||||
Enter same passphrase again:
|
||||
Your identification has been saved in /var/pretix/.ssh/id_rsa.
|
||||
Your public key has been saved in /var/pretix/.ssh/id_rsa.pub.
|
||||
|
||||
Next, send the content of the *public* key to your sales representative at pretix::
|
||||
|
||||
$ cat /var/pretix/.ssh/id_rsa.pub
|
||||
ssh-rsa AAAAB3N...744HZawHlD pretix@foo
|
||||
|
||||
After we configured your key in our system, you can install the plugin directly using ``pip`` from the URL we told
|
||||
you, for example::
|
||||
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install -U "git+ssh://git@code.rami.io:10022/pretix/pretix-slack.git@stable#egg=pretix-slack"
|
||||
(venv)$ python -m pretix migrate
|
||||
(venv)$ python -m pretix rebuild
|
||||
# systemctl restart pretix-web pretix-worker
|
||||
|
||||
Docker installation
|
||||
-------------------
|
||||
|
||||
To install a plugin, you need to build your own docker image. To do so, create a new directory to work in. As a first
|
||||
step, generate a new SSH key in that directory to use for authentication with us::
|
||||
|
||||
$ cd /home/me/mypretixdocker
|
||||
$ ssh-keygen -N "" -f id_pretix_enterprise
|
||||
|
||||
Next, send the content of the *public* key to your sales representative at pretix::
|
||||
|
||||
$ cat id_pretix_enterprise.pub
|
||||
ssh-rsa AAAAB3N...744HZawHlD pretix@foo
|
||||
|
||||
After we configured your key in our system, you can add a ``Dockerfile`` in your directory that includes the newly
|
||||
generated key and installs the plugin from the URL we told you::
|
||||
|
||||
FROM pretix/standalone:stable
|
||||
USER root
|
||||
COPY id_pretix_enterprise /root/.ssh/id_rsa
|
||||
COPY id_pretix_enterprise.pub /root/.ssh/id_rsa.pub
|
||||
RUN chmod -R 0600 /root/.ssh && \
|
||||
mkdir -p /etc/ssh && \
|
||||
ssh-keyscan -t rsa -p 10022 code.rami.io >> /root/.ssh/known_hosts && \
|
||||
echo StrictHostKeyChecking=no >> /root/.ssh/config && \
|
||||
pip3 install -Ue "git+ssh://git@code.rami.io:10022/pretix/pretix-slack.git@stable#egg=pretix-slack" && \
|
||||
cd /pretix/src && \
|
||||
sudo -u pretixuser make production
|
||||
USER pretixuser
|
||||
|
||||
Then, build the image for docker::
|
||||
|
||||
$ docker build -t mypretix
|
||||
|
||||
You can now use that image ``mypretix`` instead of ``pretix/standalone:stable`` in your ``/etc/systemd/system/pretix.service``
|
||||
service file. Be sure to re-build your custom image after you pulled ``pretix/standalone`` if you want to perform an
|
||||
update to a new version of pretix.
|
||||
|
||||
.. _price list: https://pretix.eu/about/en/pricing
|
||||
@@ -21,6 +21,9 @@ To use pretix, you will need the following things:
|
||||
|
||||
.. warning:: Do not ever use SQLite in production. It will break.
|
||||
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
* A **reverse proxy**. pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix
|
||||
is capable of doing this, having this handled by a proper web server like **nginx** or **Apache** will be much
|
||||
faster. Also, you need a proxying web server in front to provide SSL encryption.
|
||||
|
||||
@@ -10,3 +10,5 @@ for your needs.
|
||||
general
|
||||
docker_smallscale
|
||||
manual_smallscale
|
||||
dev_version
|
||||
enterprise
|
||||
|
||||
@@ -23,7 +23,7 @@ installation guides):
|
||||
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `MySQL`_ or `PostgreSQL`_ database server
|
||||
* A `PostgreSQL`_, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `redis`_ server
|
||||
|
||||
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
||||
@@ -33,6 +33,9 @@ Linux and firewalls, we recommend that you start with `ufw`_.
|
||||
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
|
||||
installations except for evaluation purposes.
|
||||
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
Unix user
|
||||
---------
|
||||
|
||||
@@ -50,7 +53,7 @@ Having the database server installed, we still need a database and a database us
|
||||
of database managing tool or directly on our database's shell, e.g. for MySQL::
|
||||
|
||||
$ mysql -u root -p
|
||||
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;
|
||||
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
mysql> GRANT ALL PRIVILEGES ON pretix.* TO pretix@'localhost' IDENTIFIED BY '*********';
|
||||
mysql> FLUSH PRIVILEGES;
|
||||
|
||||
@@ -61,7 +64,7 @@ To build and run pretix, you will need the following debian packages::
|
||||
|
||||
# apt-get install git build-essential python-dev python-virtualenv python3 python3-pip \
|
||||
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
||||
gettext libpq-dev libmysqlclient-dev libjpeg-dev
|
||||
gettext libpq-dev libmysqlclient-dev libjpeg-dev libopenjp2-7-dev
|
||||
|
||||
Config file
|
||||
-----------
|
||||
|
||||
@@ -148,6 +148,7 @@ Field specific input errors include the name of the offending fields as keys in
|
||||
|
||||
{"amount": ["A valid integer is required."], "description": ["This field may not be blank."]}
|
||||
|
||||
If you see errors of type ``429 Too Many Requests``, you should read our documentation on :ref:`rest-ratelimit`.
|
||||
|
||||
Data types
|
||||
----------
|
||||
|
||||
@@ -16,3 +16,5 @@ in functionality over time.
|
||||
fundamentals
|
||||
auth
|
||||
resources/index
|
||||
ratelimit
|
||||
webhooks
|
||||
|
||||
@@ -166,6 +166,42 @@ endpoint to revoke it.
|
||||
If you want to revoke your client secret, you can generate a new one in the list of your managed applications in the
|
||||
pretix user interface.
|
||||
|
||||
Fetching the user profile
|
||||
-------------------------
|
||||
|
||||
If you need the user's meta data, you can fetch it here:
|
||||
|
||||
.. http:get:: /api/v1/me
|
||||
|
||||
Returns the profile of the authenticated user
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/me HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Authorization: Bearer i3ytqTSRWsKp16fqjekHXa4tdM4qNC
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
email: "admin@localhost",
|
||||
fullname: "John Doe",
|
||||
locale: "de",
|
||||
timezone: "Europe/Berlin"
|
||||
}
|
||||
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
.. _OAuth2: https://en.wikipedia.org/wiki/OAuth
|
||||
.. _OAuth2 Simplified: https://aaronparecki.com/oauth-2-simplified/
|
||||
.. _HTTP Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication
|
||||
|
||||
31
doc/api/ratelimit.rst
Normal file
31
doc/api/ratelimit.rst
Normal file
@@ -0,0 +1,31 @@
|
||||
.. _`rest-ratelimit`:
|
||||
|
||||
Rate limiting
|
||||
=============
|
||||
|
||||
.. note:: This page only applies to the pretix Hosted service at pretix.eu. APIs of custom pretix installations do not
|
||||
enforce any rate limiting by default.
|
||||
|
||||
All authenticated requests to pretix' API are rate limited. If you exceed the limits, you will receive a response
|
||||
with HTTP status code ``429 Too Many Requests``. This response will have a ``Retry-After`` header, containing the number
|
||||
of seconds you are supposed to wait until you try again. We expect that all API clients respect this. If you continue
|
||||
to burst requests after a ``429`` status code, we might get in touch with you or, in extreme cases, disable your API
|
||||
access.
|
||||
|
||||
Currently, the following rate limits apply:
|
||||
|
||||
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== =================================================================================
|
||||
Authentication method Rate limit
|
||||
===================================== =================================================================================
|
||||
:ref:`rest-deviceauth` 360 requests per minute per device
|
||||
:ref:`rest-tokenauth` 360 requests per minute per organizer account
|
||||
:ref:`rest-oauth` 360 requests per minute per combination of accessed organizer and OAuth application
|
||||
Session authentication *Not an officially supported authentication method for external access*
|
||||
===================================== =================================================================================
|
||||
|
||||
If you require a higher rate limit, please get in touch at support@pretix.eu and tell us about your use case, we are
|
||||
sure we can work something out.
|
||||
@@ -25,6 +25,7 @@ item integer ID of the item
|
||||
variation integer ID of the variation (or ``null``)
|
||||
price money (string) Price of this position
|
||||
attendee_name string Specified attendee name for this position (or ``null``)
|
||||
attendee_name_parts object of strings Composition of attendee name (i.e. first name, last name, …)
|
||||
attendee_email string Specified attendee email address for this position (or ``null``)
|
||||
voucher integer Internal ID of the voucher used for this position (or ``null``)
|
||||
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
|
||||
@@ -78,6 +79,7 @@ Cart position endpoints
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": null,
|
||||
"attendee_name_parts": {},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"addon_to": null,
|
||||
@@ -122,6 +124,7 @@ Cart position endpoints
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": null,
|
||||
"attendee_name_parts": {},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"addon_to": null,
|
||||
@@ -175,7 +178,7 @@ Cart position endpoints
|
||||
* ``item``
|
||||
* ``variation`` (optional)
|
||||
* ``price``
|
||||
* ``attendee_name`` (optional)
|
||||
* ``attendee_name`` **or** ``attendee_name_parts`` (optional)
|
||||
* ``attendee_email`` (optional)
|
||||
* ``subevent`` (optional)
|
||||
* ``expires`` (optional)
|
||||
@@ -199,7 +202,10 @@ Cart position endpoints
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"given_name": "Peter",
|
||||
"family_name": "Miller"
|
||||
},
|
||||
"attendee_email": null,
|
||||
"answers": [
|
||||
{
|
||||
|
||||
@@ -371,6 +371,9 @@ Order position endpoints
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
@@ -442,6 +445,8 @@ Order position endpoints
|
||||
The result format is the same as the :ref:`order-position-resource`, with one important difference: the
|
||||
``checkins`` value will only include check-ins for the selected list.
|
||||
|
||||
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -466,6 +471,9 @@ Order position endpoints
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
@@ -510,6 +518,8 @@ Order position endpoints
|
||||
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
|
||||
accepts a number of optional requests in the body.
|
||||
|
||||
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
|
||||
|
||||
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||
you do not implement question handling in your user interface, you **must**
|
||||
set this to ``false``. In that case, questions will just be ignored. Defaults
|
||||
@@ -555,7 +565,10 @@ Order position endpoints
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "ok"
|
||||
"status": "ok",
|
||||
"position": {
|
||||
…
|
||||
}
|
||||
}
|
||||
|
||||
**Example response with required questions**:
|
||||
@@ -566,7 +579,10 @@ Order position endpoints
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "incomplete"
|
||||
"status": "incomplete",
|
||||
"position": {
|
||||
…
|
||||
},
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -611,6 +627,9 @@ Order position endpoints
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "unpaid",
|
||||
"position": {
|
||||
…
|
||||
}
|
||||
}
|
||||
|
||||
Possible error reasons:
|
||||
|
||||
@@ -15,6 +15,7 @@ name multi-lingual string The event's ful
|
||||
slug string A short form of the name, used e.g. in URLs.
|
||||
live boolean If ``true``, the event ticket shop is publicly
|
||||
available.
|
||||
testmode boolean If ``true``, the ticket shop is in test mode.
|
||||
currency string The currency this event is handled in.
|
||||
date_from datetime The event's start date
|
||||
date_to datetime The event's end date (or ``null``)
|
||||
@@ -45,6 +46,10 @@ plugins list A list of packa
|
||||
|
||||
Filters have been added to the list of events.
|
||||
|
||||
.. versionchanged:: 2.5
|
||||
|
||||
The ``testmode`` attribute has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -79,6 +84,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -137,6 +143,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -183,6 +190,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -211,6 +219,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -259,6 +268,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -287,6 +297,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -347,6 +358,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
|
||||
@@ -21,3 +21,4 @@ Resources and endpoints
|
||||
checkinlists
|
||||
waitinglist
|
||||
carts
|
||||
webhooks
|
||||
|
||||
@@ -37,6 +37,8 @@ admission boolean ``True`` for it
|
||||
position integer An integer, used for sorting
|
||||
picture string A product picture to be displayed in the shop
|
||||
(read-only).
|
||||
sales_channels list of strings Sales channels this product is available on, such as
|
||||
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
|
||||
available_from datetime The first date time at which this item can be bought
|
||||
(or ``null``).
|
||||
available_until datetime The last date time at which this item can be bought
|
||||
@@ -62,6 +64,12 @@ original_price money (string) An original pri
|
||||
require_approval boolean If ``True``, orders with this product will need to be
|
||||
approved by the event organizer before they can be
|
||||
paid.
|
||||
generate_tickets boolean If ``False``, tickets are never generated for this
|
||||
product, regardless of other settings. If ``True``,
|
||||
tickets are generated even if this is a
|
||||
non-admission or add-on product, regardless of event
|
||||
settings. If this is ``null``, regular ticketing
|
||||
rules apply.
|
||||
has_variations boolean Shows whether or not this item has variations.
|
||||
variations list of objects A list with one object for each variation of this item.
|
||||
Can be empty. Only writable during creation,
|
||||
@@ -105,6 +113,14 @@ addons list of objects Definition of a
|
||||
|
||||
The field ``require_approval`` has been added.
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
The ``sales_channels`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.4
|
||||
|
||||
The ``generate_tickets`` attribute has been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Please note that an item either always has variations or never has. Once created with variations the item can never
|
||||
@@ -147,6 +163,7 @@ Endpoints
|
||||
"id": 1,
|
||||
"name": {"en": "Standard ticket"},
|
||||
"internal_name": "",
|
||||
"sales_channels": ["web"],
|
||||
"default_price": "23.00",
|
||||
"original_price": null,
|
||||
"category": null,
|
||||
@@ -167,6 +184,7 @@ Endpoints
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": false,
|
||||
"generate_tickets": null,
|
||||
"require_approval": false,
|
||||
"variations": [
|
||||
{
|
||||
@@ -232,6 +250,7 @@ Endpoints
|
||||
"id": 1,
|
||||
"name": {"en": "Standard ticket"},
|
||||
"internal_name": "",
|
||||
"sales_channels": ["web"],
|
||||
"default_price": "23.00",
|
||||
"original_price": null,
|
||||
"category": null,
|
||||
@@ -248,6 +267,7 @@ Endpoints
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"generate_tickets": null,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
@@ -298,6 +318,7 @@ Endpoints
|
||||
"id": 1,
|
||||
"name": {"en": "Standard ticket"},
|
||||
"internal_name": "",
|
||||
"sales_channels": ["web"],
|
||||
"default_price": "23.00",
|
||||
"original_price": null,
|
||||
"category": null,
|
||||
@@ -314,6 +335,7 @@ Endpoints
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"generate_tickets": null,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
@@ -351,6 +373,7 @@ Endpoints
|
||||
"id": 1,
|
||||
"name": {"en": "Standard ticket"},
|
||||
"internal_name": "",
|
||||
"sales_channels": ["web"],
|
||||
"default_price": "23.00",
|
||||
"original_price": null,
|
||||
"category": null,
|
||||
@@ -369,6 +392,7 @@ Endpoints
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"generate_tickets": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": true,
|
||||
"require_approval": false,
|
||||
@@ -436,6 +460,7 @@ Endpoints
|
||||
"id": 1,
|
||||
"name": {"en": "Ticket"},
|
||||
"internal_name": "",
|
||||
"sales_channels": ["web"],
|
||||
"default_price": "25.00",
|
||||
"original_price": null,
|
||||
"category": null,
|
||||
@@ -451,6 +476,7 @@ Endpoints
|
||||
"available_until": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"generate_tickets": null,
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
|
||||
@@ -26,10 +26,13 @@ status string Order status, o
|
||||
* ``p`` – paid
|
||||
* ``e`` – expired
|
||||
* ``c`` – canceled
|
||||
* ``r`` – refunded
|
||||
testmode boolean If ``True``, this order was created when the event was in
|
||||
test mode. Only orders in test mode can be deleted.
|
||||
secret string The secret contained in the link sent to the customer
|
||||
email string The customer email address
|
||||
locale string The locale used for communication with this customer
|
||||
sales_channel string Channel this sale was created through, such as
|
||||
``"web"``.
|
||||
datetime datetime Time of order creation
|
||||
expires datetime The order will expire, if it is still pending by this time
|
||||
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
|
||||
@@ -46,6 +49,7 @@ invoice_address object Invoice address
|
||||
for orders created before pretix 1.7, do not rely on
|
||||
it).
|
||||
├ name string Customer name
|
||||
├ name_parts object of strings Customer name decomposition
|
||||
├ street string Customer street
|
||||
├ zipcode string Customer ZIP code
|
||||
├ city string Customer city
|
||||
@@ -55,9 +59,9 @@ invoice_address object Invoice address
|
||||
└ vat_id_validated string ``True``, if the VAT ID has been validated against the
|
||||
EU VAT service and validation was successful. This only
|
||||
happens in rare cases.
|
||||
positions list of objects List of order positions (see below)
|
||||
fees list of objects List of fees included in the order total (i.e.
|
||||
payment fees)
|
||||
positions list of objects List of non-canceled order positions (see below)
|
||||
fees list of objects List of non-canceled fees included in the order total
|
||||
(i.e. payment fees)
|
||||
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
|
||||
``other``)
|
||||
├ value money (string) Fee amount
|
||||
@@ -120,6 +124,19 @@ last_modified datetime Last modificati
|
||||
nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. The ``require_approval``
|
||||
attribute has been added, as have been the ``…/approve/`` and ``…/deny/`` endpoints.
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
The ``sales_channel`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.4:
|
||||
|
||||
``order.status`` can no longer be ``r``, ``…/mark_canceled/`` now accepts a ``cancellation_fee`` parameter and
|
||||
``…/mark_refunded/`` has been deprecated.
|
||||
|
||||
.. versionchanged:: 2.5:
|
||||
|
||||
The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders.
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
Order position resource
|
||||
@@ -137,6 +154,7 @@ item integer ID of the purch
|
||||
variation integer ID of the purchased variation (or ``null``)
|
||||
price money (string) Price of this position
|
||||
attendee_name string Specified attendee name for this position (or ``null``)
|
||||
attendee_name_parts object of strings Decomposition of attendee name (i.e. given name, family name)
|
||||
attendee_email string Specified attendee email address for this position (or ``null``)
|
||||
voucher integer Internal ID of the voucher used for this position (or ``null``)
|
||||
tax_rate decimal (string) VAT rate applied for this position
|
||||
@@ -260,9 +278,11 @@ List of all orders
|
||||
{
|
||||
"code": "ABC12",
|
||||
"status": "p",
|
||||
"testmode": false,
|
||||
"secret": "k24fiuwvu8kxz3y1",
|
||||
"email": "tester@example.org",
|
||||
"locale": "en",
|
||||
"sales_channel": "web",
|
||||
"datetime": "2017-12-01T10:00:00Z",
|
||||
"expires": "2017-12-10T10:00:00Z",
|
||||
"last_modified": "2017-12-01T10:00:00Z",
|
||||
@@ -278,6 +298,7 @@ List of all orders
|
||||
"is_business": True,
|
||||
"company": "Sample company",
|
||||
"name": "John Doe",
|
||||
"name_parts": {"full_name": "John Doe"},
|
||||
"street": "Test street 12",
|
||||
"zipcode": "12345",
|
||||
"city": "Testington",
|
||||
@@ -295,6 +316,9 @@ List of all orders
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
@@ -353,11 +377,14 @@ List of all orders
|
||||
``status``. Default: ``datetime``
|
||||
:query string code: Only return orders that match the given order code
|
||||
:query string status: Only return orders in the given order status (see above)
|
||||
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
|
||||
:query boolean require_approval: If set to ``true`` or ``false``, only categories with this value for the field
|
||||
``require_approval`` will be returned.
|
||||
:query string email: Only return orders created with the given email address
|
||||
:query string locale: Only return orders with the given customer locale
|
||||
:query datetime modified_since: Only return orders that have changed since the given date
|
||||
:query datetime modified_since: Only return orders that have changed since the given date. Be careful: We only
|
||||
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
|
||||
you will not notice it using this method.
|
||||
: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
|
||||
@@ -392,9 +419,11 @@ Fetching individual orders
|
||||
{
|
||||
"code": "ABC12",
|
||||
"status": "p",
|
||||
"testmode": false,
|
||||
"secret": "k24fiuwvu8kxz3y1",
|
||||
"email": "tester@example.org",
|
||||
"locale": "en",
|
||||
"sales_channel": "web",
|
||||
"datetime": "2017-12-01T10:00:00Z",
|
||||
"expires": "2017-12-10T10:00:00Z",
|
||||
"last_modified": "2017-12-01T10:00:00Z",
|
||||
@@ -410,6 +439,7 @@ Fetching individual orders
|
||||
"company": "Sample company",
|
||||
"is_business": True,
|
||||
"name": "John Doe",
|
||||
"name_parts": {"full_name": "John Doe"},
|
||||
"street": "Test street 12",
|
||||
"zipcode": "12345",
|
||||
"city": "Testington",
|
||||
@@ -427,6 +457,9 @@ Fetching individual orders
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
@@ -530,6 +563,37 @@ Order ticket download
|
||||
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||
seconds.
|
||||
|
||||
Deleting orders
|
||||
---------------
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
|
||||
|
||||
Deletes an order. Works only if the order has ``testmode`` set to ``true``.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param code: The ``code`` field of the order to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource **or** the order may not be deleted.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
|
||||
Creating orders
|
||||
---------------
|
||||
|
||||
@@ -551,6 +615,8 @@ Creating orders
|
||||
|
||||
* does not validate if products are only to be sold in a specific time frame
|
||||
|
||||
* does not validate if products are only to be sold on other sales channels
|
||||
|
||||
* does not validate if the event's ticket sales are already over or haven't started
|
||||
|
||||
* does not validate the number of items per order or the number of times an item can be included in an order
|
||||
@@ -582,11 +648,13 @@ Creating orders
|
||||
or in state ``confirmed``, depending on this value. If you create a paid order, the ``order_paid`` signal will
|
||||
**not** be sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and
|
||||
then call the ``mark_paid`` API method.
|
||||
* ``testmode`` (optional) – Defaults to ``false``
|
||||
* ``consume_carts`` (optional) – A list of cart IDs. All cart positions with these IDs will be deleted if the
|
||||
order creation is successful. Any quotas that become free by this operation will be credited to your order
|
||||
creation.
|
||||
* ``email``
|
||||
* ``locale``
|
||||
* ``sales_channel``
|
||||
* ``payment_provider`` – The identifier of the payment provider set for this order. This needs to be an existing
|
||||
payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` for all
|
||||
orders you create as paid.
|
||||
@@ -601,7 +669,7 @@ Creating orders
|
||||
|
||||
* ``company``
|
||||
* ``is_business``
|
||||
* ``name``
|
||||
* ``name`` **or** ``name_parts``
|
||||
* ``street``
|
||||
* ``zipcode``
|
||||
* ``city``
|
||||
@@ -615,7 +683,7 @@ Creating orders
|
||||
* ``item``
|
||||
* ``variation``
|
||||
* ``price``
|
||||
* ``attendee_name``
|
||||
* ``attendee_name`` **or** ``attendee_name_parts``
|
||||
* ``attendee_email``
|
||||
* ``secret`` (optional)
|
||||
* ``addon_to`` (optional, see below)
|
||||
@@ -651,6 +719,7 @@ Creating orders
|
||||
{
|
||||
"email": "dummy@example.org",
|
||||
"locale": "en",
|
||||
"sales_channel": "web",
|
||||
"fees": [
|
||||
{
|
||||
"fee_type": "payment",
|
||||
@@ -664,7 +733,7 @@ Creating orders
|
||||
"invoice_address": {
|
||||
"is_business": False,
|
||||
"company": "Sample company",
|
||||
"name": "John Doe",
|
||||
"name_parts": {"full_name": "John Doe"},
|
||||
"street": "Sesam Street 12",
|
||||
"zipcode": "12345",
|
||||
"city": "Sample City",
|
||||
@@ -678,7 +747,9 @@ Creating orders
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter"
|
||||
},
|
||||
"attendee_email": null,
|
||||
"addon_to": null,
|
||||
"answers": [
|
||||
@@ -752,7 +823,10 @@ Order state operations
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_canceled/
|
||||
|
||||
Marks a pending order as canceled.
|
||||
Cancels an order. For a pending order, this will set the order to status ``c``. For a paid order, this will set
|
||||
the order to status ``c`` if no ``cancellation_fee`` is passed. If you do pass a ``cancellation_fee``, the order
|
||||
will instead stay paid, but all positions will be removed (or marked as canceled) and replaced by the cancellation
|
||||
fee as the only component of the order.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -764,7 +838,8 @@ Order state operations
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"send_email": true
|
||||
"send_email": true,
|
||||
"cancellation_fee": null
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -825,44 +900,6 @@ Order state operations
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_refunded/
|
||||
|
||||
Marks a paid order as refunded.
|
||||
|
||||
.. warning:: In the current implementation, this will **bypass** the payment provider, i.e. the money will **not** be
|
||||
transferred back to the user automatically, the order will only be *marked* as refunded within pretix.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_expired/ 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
|
||||
|
||||
{
|
||||
"code": "ABC12",
|
||||
"status": "r",
|
||||
...
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param code: The ``code`` field of the order to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The order cannot be marked as expired since the current order status does not allow it.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_expired/
|
||||
|
||||
Marks a unpaid order as expired.
|
||||
@@ -1042,6 +1079,8 @@ List of all order positions
|
||||
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
|
||||
``pseudonymization_id``.
|
||||
|
||||
.. note:: Individually canceled order positions are currently not visible via the API at all.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
|
||||
|
||||
Returns a list of all order positions within a given event.
|
||||
@@ -1075,6 +1114,9 @@ List of all order positions
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter"
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
@@ -1172,6 +1214,9 @@ Fetching individual positions
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
@@ -1471,7 +1516,7 @@ Order payment endpoints
|
||||
|
||||
{
|
||||
"amount": "23.00",
|
||||
"mark_refunded": false
|
||||
"mark_canceled": false
|
||||
}
|
||||
|
||||
|
||||
@@ -1618,7 +1663,7 @@ Order refund endpoints
|
||||
"payment": 1,
|
||||
"execution_date": null,
|
||||
"provider": "manual",
|
||||
"mark_refunded": false
|
||||
"mark_canceled": false
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -1688,7 +1733,7 @@ Order refund endpoints
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/process/
|
||||
|
||||
Acts on an external refund, either marks the order as refunded or pending. Only allowed in state ``external``.
|
||||
Acts on an external refund, either marks the order as canceled or pending. Only allowed in state ``external``.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -1699,7 +1744,7 @@ Order refund endpoints
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{"mark_refunded": false}
|
||||
{"mark_canceled": false}
|
||||
|
||||
**Example response**:
|
||||
|
||||
|
||||
@@ -231,6 +231,76 @@ Endpoints
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
:statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/vouchers/batch_create/
|
||||
|
||||
Creates multiple new vouchers atomically.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/vouchers/batch_create/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 408
|
||||
|
||||
[
|
||||
{
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
"max_usages": 1,
|
||||
"valid_until": null,
|
||||
"block_quota": false,
|
||||
"allow_ignore_quota": false,
|
||||
"price_mode": "set",
|
||||
"value": "12.00",
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": "",
|
||||
"subevent": null
|
||||
},
|
||||
{
|
||||
"code": "ASDKLJCYXCASDASD",
|
||||
"max_usages": 1,
|
||||
"valid_until": null,
|
||||
"block_quota": false,
|
||||
"allow_ignore_quota": false,
|
||||
"price_mode": "set",
|
||||
"value": "12.00",
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": "",
|
||||
"subevent": null
|
||||
},
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
…
|
||||
}, …
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a vouchers for
|
||||
:param event: The ``slug`` field of the event to create a vouchers for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The vouchers could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
:statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/vouchers/(id)/
|
||||
|
||||
Update a voucher. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
|
||||
242
doc/api/resources/webhooks.rst
Normal file
242
doc/api/resources/webhooks.rst
Normal file
@@ -0,0 +1,242 @@
|
||||
.. _`rest-webhooks`:
|
||||
|
||||
Webhooks
|
||||
========
|
||||
|
||||
.. note:: This page is about how to modify webhook settings themselves through the REST API. If you just want to know
|
||||
how webhooks work, go here: :ref:`webhooks`
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The webhook resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the webhook
|
||||
enabled boolean If ``False``, this webhook will not receive any notifications
|
||||
target_url string The URL to call
|
||||
all_events boolean If ``True``, this webhook will receive notifications
|
||||
on all events of this organizer
|
||||
limit_events list of strings If ``all_events`` is ``False``, this is a list of
|
||||
event slugs this webhook is active for
|
||||
action_types list of strings A list of action type filters that limit the
|
||||
notifications sent to this webhook. See below for
|
||||
valid values
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
The following values for ``action_types`` are valid with pretix core:
|
||||
|
||||
* ``pretix.event.order.placed``
|
||||
* ``pretix.event.order.paid``
|
||||
* ``pretix.event.order.canceled``
|
||||
* ``pretix.event.order.expired``
|
||||
* ``pretix.event.order.modified``
|
||||
* ``pretix.event.order.contact.changed``
|
||||
* ``pretix.event.order.changed.*``
|
||||
* ``pretix.event.order.refund.created.externally``
|
||||
* ``pretix.event.order.approved``
|
||||
* ``pretix.event.order.denied``
|
||||
* ``pretix.event.checkin``
|
||||
* ``pretix.event.checkin.reverted``
|
||||
|
||||
Installed plugins might register more valid values.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/webhooks/
|
||||
|
||||
Returns a list of all webhooks within a given organizer.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/webhooks/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 2,
|
||||
"enabled": true,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/webhooks/(id)/
|
||||
|
||||
Returns information on one webhook, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/webhooks/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"enabled": true,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param id: The ``id`` field of the webhook to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/webhooks/
|
||||
|
||||
Creates a new webhook
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/webhooks/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"enabled": true,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"enabled": true,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a webhook for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The webhook could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/webhooks/(id)/
|
||||
|
||||
Update a webhook. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``id`` field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/webhooks/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"enabled": false,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the webhook to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The webhook could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/webhook/(id)/
|
||||
|
||||
Delete a webhook. Currently, this will not delete but just disable the webhook.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/webhooks/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the webhook to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource.
|
||||
108
doc/api/webhooks.rst
Normal file
108
doc/api/webhooks.rst
Normal file
@@ -0,0 +1,108 @@
|
||||
.. _`webhooks`:
|
||||
|
||||
Webhooks
|
||||
========
|
||||
|
||||
pretix can send webhook calls to notify your application of any changes that happen inside pretix. This is especially
|
||||
useful for everything triggered by an actual user, such as a new ticket sale or the arrival of a payment.
|
||||
|
||||
You can register any number of webhook URLs that pretix will notify any time one of the supported events occurs inside
|
||||
your organizer account. A great example use case of webhooks would be to add the buyer to your mailing list every time
|
||||
a new order comes in.
|
||||
|
||||
Configuring webhooks
|
||||
--------------------
|
||||
|
||||
You can find the list of your active webhooks in the "Webhook" section of your organizer account:
|
||||
|
||||
.. thumbnail:: ../screens/organizer/webhook_list.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Click "Create webhook" if you want to add a new URL. You will then be able to enter the URL pretix shall call for
|
||||
notifications. You need to select any number of notification types that you want to receive and you can optionally
|
||||
filter the events you want to receive notifications for.
|
||||
|
||||
.. thumbnail:: ../screens/organizer/webhook_edit.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
You can also configure webhooks :ref:`through the API itself <rest-webhooks>`.
|
||||
|
||||
Receiving webhooks
|
||||
------------------
|
||||
|
||||
Creating a webhook endpoint on your server is no different from creating any other page on your website. If your
|
||||
website is written in PHP, you might just create a new ``.php`` file on your server; if you use a web framework like
|
||||
Symfony or Django, you would just create a new route with the desired URL.
|
||||
|
||||
We will call your URL with a HTTP ``POST`` request with a ``JSON`` body. In PHP, you can parse this like this::
|
||||
|
||||
$input = @file_get_contents('php://input');
|
||||
$event_json = json_decode($input);
|
||||
// Do something with $event_json
|
||||
|
||||
In Django, you would create a view like this::
|
||||
|
||||
def my_webhook_view(request):
|
||||
event_json = json.loads(request.body)
|
||||
# Do something with event_json
|
||||
return HttpResponse(status=200)
|
||||
|
||||
More samples for the language of your choice are easy to find online.
|
||||
|
||||
The exact body of the request varies by notification type, but for the main types included with pretix core, such as
|
||||
those related to changes of an order, it will look like this::
|
||||
|
||||
{
|
||||
"notification_id": 123455,
|
||||
"organizer": "acmecorp",
|
||||
"event": "democon",
|
||||
"code": "ABC23",
|
||||
"action": "pretix.event.order.placed"
|
||||
}
|
||||
|
||||
Notifications regarding a check-in will contain more details like ``orderposition_id``
|
||||
and ``checkin_list``.
|
||||
|
||||
.. warning:: You should not trust data supplied to your webhook, but only use it as a trigger to fetch updated data.
|
||||
Anyone could send data there if they guess the correct URL and you won't be able to tell. Therefore, we
|
||||
only include the minimum amount of data necessary for you to fetch the changed objects from our
|
||||
:ref:`rest-api` in an authenticated way.
|
||||
|
||||
If you want to further prevent others from accessing your webhook URL, you can also use `Basic authentication`_ and
|
||||
supply the URL to us in the format of ``https://username:password@domain.com/path/``.
|
||||
We recommend that you use HTTPS for your webhook URL and might require it in the future. If HTTPS is used, we require
|
||||
that a valid certificate is in use.
|
||||
|
||||
.. note:: If you use a web framework that makes use of automatic CSRF protection, this protection might prevent us
|
||||
from calling your webhook URL. In this case, we recommend that you turn of CSRF protection selectively
|
||||
for that route. In Django, you can do this by putting the ``@csrf_exempt`` decorator on your view. In
|
||||
Rails, you can pass an ``except`` parameter to ``protect_from_forgery``.
|
||||
|
||||
|
||||
Responding to a webhook
|
||||
-----------------------
|
||||
|
||||
If you successfully received a webhook call, your endpoint should return a HTTP status code between ``200`` and ``299``.
|
||||
If any other status code is returned, we will assume you did not receive the call. This does mean that any redirection
|
||||
or ``304 Not Modified`` response will be treated as a failure. pretix will not follow any ``301`` or ``302`` redirect
|
||||
headers and pretix will ignore all other information in your response headers or body.
|
||||
|
||||
If we do not receive a status code in the range of ``200`` and ``299``, pretix will retry to deliver for up to three
|
||||
days with an exponential back off. Therefore, we recommend that you implement your endpoint in a way where calling it
|
||||
multiple times for the same event due to a perceived error does not do any harm.
|
||||
|
||||
There is only one exception: If status code ``410 Gone`` is returned, we will assume the
|
||||
endpoint does not exist any more and automatically disable the webhook.
|
||||
|
||||
.. note:: If you use a self-hosted version of pretix (i.e. not our SaaS offering at pretix.eu) and you did not
|
||||
configure a background task queue, failed webhooks will not be retried.
|
||||
|
||||
Debugging webhooks
|
||||
------------------
|
||||
|
||||
If you want to debug your webhooks, you can view a log of all sent notifications and the responses of your server for
|
||||
30 days right next to your configuration.
|
||||
|
||||
.. _Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication
|
||||
@@ -12,7 +12,7 @@ Core
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
|
||||
item_copy_data
|
||||
item_copy_data, register_sales_channels
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
@@ -26,7 +26,7 @@ Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content
|
||||
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
@@ -49,7 +49,7 @@ Backend
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
|
||||
order_info, event_settings_widget, oauth_application_registered
|
||||
order_info, event_settings_widget, oauth_application_registered, order_position_buttons
|
||||
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
|
||||
@@ -23,7 +23,7 @@ that we'll provide in this plugin::
|
||||
|
||||
|
||||
@receiver(register_invoice_renderers, dispatch_uid="output_custom")
|
||||
def register_infoice_renderers(sender, **kwargs):
|
||||
def register_invoice_renderers(sender, **kwargs):
|
||||
from .invoice import MyInvoiceRenderer
|
||||
return MyInvoiceRenderer
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ The provider class
|
||||
|
||||
.. autoattribute:: settings_form_fields
|
||||
|
||||
.. automethod:: settings_form_clean
|
||||
|
||||
.. automethod:: settings_content_render
|
||||
|
||||
.. automethod:: is_allowed
|
||||
@@ -112,6 +114,8 @@ The provider class
|
||||
|
||||
.. autoattribute:: is_meta
|
||||
|
||||
.. autoattribute:: test_mode_message
|
||||
|
||||
|
||||
Additional views
|
||||
----------------
|
||||
|
||||
@@ -79,6 +79,9 @@ human-readable error messages. We recommend using the ``django.utils.functional.
|
||||
decorator, as it might get called a lot. You can also implement ``compatibility_warnings``,
|
||||
those will be displayed but not block the plugin execution.
|
||||
|
||||
The ``AppConfig`` class may implement a method ``is_available(event)`` that checks if a plugin
|
||||
is available for a specific event. If not, it will not be shown in the plugin list of that event.
|
||||
|
||||
Plugin registration
|
||||
-------------------
|
||||
|
||||
|
||||
@@ -82,6 +82,12 @@ Orders
|
||||
^^^^^^
|
||||
|
||||
If a customer completes the checkout process, an **Order** will be created containing all the entered information.
|
||||
An order can be in one of currently five states that are listed in the diagram below:
|
||||
An order can be in one of currently four states that are listed in the diagram below:
|
||||
|
||||
.. image:: /images/order_states.png
|
||||
|
||||
There are additional "fake" states that are displayed like states but not represented as states in the system:
|
||||
|
||||
* An order is considered **canceled (with paid fee)** if it is in **paid** status but does not include any non-cancelled positions.
|
||||
|
||||
* An order is considered **requiring approval** if it is in **pending** status with the ``require_approval`` attribute set to ``True``.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 53 KiB |
@@ -4,7 +4,6 @@ Pending: Order is expecting payment\nOrder reduces quotas
|
||||
Expired: Payment period is over\nOrder does not affect quotas
|
||||
Paid: Order was successful\nOrder reduces quotas
|
||||
Canceled: Order has been canceled\nOrder does not affect quotas
|
||||
Refunded: Order has been refunded\nOrder does not affect quotas
|
||||
|
||||
[*] --> Pending: customer\nplaces order
|
||||
Pending --> Paid: successful payment
|
||||
@@ -12,8 +11,9 @@ Pending --> Expired: automatically\nor manually\non admin action
|
||||
Expired --> Paid: if payment is received\nonly if quota left
|
||||
Expired --> Canceled
|
||||
Expired --> Pending: manually\non admin action
|
||||
Paid --> Refunded: manually on\nadmin action\nor if an external\npayment provider\nnotifies about a\npayment refund
|
||||
Paid --> Canceled: manually on\nadmin action\nor if an external\npayment provider\nnotifies about a\npayment refund
|
||||
Pending --> Canceled: on admin or\ncustomer action
|
||||
Paid -> Pending: manually on admin action
|
||||
[*] --> Paid: customer\nplaces free order
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -20,6 +20,7 @@ default boolean ``true`` if thi
|
||||
layout object Layout specification for libpretixprint
|
||||
background URL Background PDF file
|
||||
item_assignments list of objects Products this layout is assigned to
|
||||
├ sales_channel string Sales channel (defaults to ``web``).
|
||||
└ item integer Item ID
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
@@ -27,6 +28,10 @@ item_assignments list of objects Products this l
|
||||
|
||||
This resource has been added.
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
The ``item_assignments.sales_channel`` field has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
BIN
doc/screens/organizer/webhook_edit.png
Normal file
BIN
doc/screens/organizer/webhook_edit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
doc/screens/organizer/webhook_list.png
Normal file
BIN
doc/screens/organizer/webhook_list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -1,10 +1,12 @@
|
||||
addon
|
||||
addons
|
||||
Analytics
|
||||
anonymize
|
||||
api
|
||||
auditability
|
||||
auth
|
||||
autobuild
|
||||
availabilities
|
||||
backend
|
||||
backends
|
||||
banktransfer
|
||||
@@ -65,6 +67,7 @@ ons
|
||||
optimizations
|
||||
overpayment
|
||||
param
|
||||
passphrase
|
||||
percental
|
||||
positionid
|
||||
pre
|
||||
@@ -89,6 +92,7 @@ regex
|
||||
renderer
|
||||
renderers
|
||||
reportlab
|
||||
SaaS
|
||||
screenshot
|
||||
selectable
|
||||
serializers
|
||||
@@ -105,7 +109,9 @@ subevent
|
||||
subevents
|
||||
submodule
|
||||
subpath
|
||||
Symfony
|
||||
systemd
|
||||
testmode
|
||||
testutils
|
||||
timestamp
|
||||
tuples
|
||||
|
||||
@@ -149,8 +149,101 @@ Just as the widget, the button supports the optional attributes ``voucher`` and
|
||||
|
||||
You can style the button using the ``pretix-button`` CSS class.
|
||||
|
||||
.. versionchanged:: 1.13
|
||||
Dynamically loading the widget
|
||||
------------------------------
|
||||
|
||||
The pretix Button has been added in version 1.13.
|
||||
If you need to control the way or timing the widget loads, for example because you want to modify user data (see
|
||||
below) dynamically via JavaScript, you can register a listener that we will call before creating the widget::
|
||||
|
||||
<script type="text/javascript">
|
||||
window.pretixWidgetCallback = function () {
|
||||
// Will be run before we create the widget.
|
||||
}
|
||||
</script>
|
||||
|
||||
If you want, you can suppress us loading the widget and/or modify the user data passed to the widget::
|
||||
|
||||
<script type="text/javascript">
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.PretixWidget.widget_data["email"] = "test@example.org";
|
||||
}
|
||||
</script>
|
||||
|
||||
If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``.
|
||||
|
||||
|
||||
Passing user data to the widget
|
||||
-------------------------------
|
||||
|
||||
If you display the widget in a restricted area of your website and you want to pre-fill fields in the checkout process
|
||||
with known user data to save your users some typing and increase conversions, you can pass additional data attributes
|
||||
with that information::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/"
|
||||
data-attendee-name-given-name="John"
|
||||
data-attendee-name-family-name="Doe"
|
||||
data-invoice-address-name-given-name="John"
|
||||
data-invoice-address-name-family-name="Doe"
|
||||
data-email="test@example.org"
|
||||
data-question-L9G8NG9M="Foobar">
|
||||
</pretix-widget>
|
||||
|
||||
This works for the pretix Button as well. Currently, the following attributes are understood by pretix itself:
|
||||
|
||||
* ``data-email`` will pre-fill the order email field as well as the attendee email field (if enabled).
|
||||
|
||||
* ``data-question-IDENTIFIER`` will pre-fill the answer for the question with the given identifier. You can view and set
|
||||
identifiers in the *Questions* section of the backend.
|
||||
|
||||
* Depending on the person name scheme configured in your event settings, you can pass one or more of
|
||||
``data-attendee-name-full-name``, ``data-attendee-name-given-name``, ``data-attendee-name-family-name``,
|
||||
``data-attendee-name-middle-name``, ``data-attendee-name-title``, ``data-attendee-name-calling-name``,
|
||||
``data-attendee-name-latin-transcription``. If you don't know or don't care, you can also just pass a string as
|
||||
``data-attendee-name``, which will pre-fill the last part of the name, whatever that is.
|
||||
|
||||
* ``data-invoice-address-FIELD`` will pre-fill the corresponding field of the invoice address. Possible values for
|
||||
``FIELD`` are ``company``, ``street``, ``zipcode``, ``city`` and ``country``, as well as fields specified by the
|
||||
naming scheme such as ``name-title`` or ``name-given-name`` (see above). ``country`` expects a two-character
|
||||
country code.
|
||||
|
||||
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
|
||||
Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
|
||||
* If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders
|
||||
made through this widget will be counted towards this campaign.
|
||||
|
||||
* If you use the tracking plugin, you can pass a Google Analytics User ID to enable cross-domain tracking. This will
|
||||
require you to dynamically load the widget, like this::
|
||||
|
||||
<script>
|
||||
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
||||
|
||||
ga('create', 'UA-XXXXXX-1', 'auto');
|
||||
ga('send', 'pageview');
|
||||
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||
if(window.ga && ga.create) {
|
||||
ga(function(tracker) {
|
||||
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
|
||||
window.PretixWidget.buildWidgets()
|
||||
});
|
||||
} else { // Tracking is probably blocked
|
||||
window.PretixWidget.buildWidgets()
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
Data passing options have been added in pretix 2.3. If you use a self-hosted version of pretix, they only work
|
||||
fully if you configured a redis server.
|
||||
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
|
||||
@@ -4,22 +4,10 @@ FAQ and Troubleshooting
|
||||
How can I test my shop before taking it live?
|
||||
---------------------------------------------
|
||||
|
||||
There are multiple ways to do this.
|
||||
|
||||
First, you could just create some orders in your real shop and cancel/refund them later. If you don't want to process
|
||||
real payments for the tests, you can either use a "manual" payment method like bank transfer and just mark the orders
|
||||
as paid with the button in the backend, or if you want to use e.g. Stripe, you can configure pretix to use your keys
|
||||
for the Stripe test system and use their test credit cars. Read our :ref:`Stripe documentation <stripe>` for more
|
||||
information.
|
||||
|
||||
Second, you could create a separate event, just for testing. In the last step of the :ref:`event creation process <event_create>`,
|
||||
you can specify that you want to copy all settings from your real event, so you don't have to do all of it twice.
|
||||
|
||||
We are planning to add a dedicated test mode in a later version of pretix.
|
||||
|
||||
If you are using the hosted service at pretix.eu and want to get rid of the test orders completely, contact us at
|
||||
support@pretix.eu and we can remove them for you. Please note that we only are able to do that *before* you have
|
||||
received any real orders (i.e. taken the shop public). We won't charge any fees for test orders or test events.
|
||||
On your event dashboard, click on the first tile that shows your shop status. On the lower part of this page, you can
|
||||
place your event into "test mode". In "test mode", everything behaves the same, but orders created during test mode can
|
||||
later be fully deleted. Be sure to actually delete them when or after you turn off test mode, since test mode orders
|
||||
still count toward your quotas and are included in your reports.
|
||||
|
||||
How do I delete an event?
|
||||
-------------------------
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[run]
|
||||
source = pretix
|
||||
omit = */migrations/*,*/urls.py,*/tests/*,*/testdummy/*,*/admin.py,pretix/wsgi.py,pretix/settings.py
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __str__
|
||||
der __repr__
|
||||
if settings.DEBUG
|
||||
NOQA
|
||||
NotImplementedError
|
||||
@@ -34,4 +34,5 @@ git push
|
||||
# Unlock Weblate
|
||||
for c in $COMPONENTS; do
|
||||
wlc unlock $c;
|
||||
wlc pull $c;
|
||||
done
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.1.0"
|
||||
__version__ = "2.5.0.dev0"
|
||||
|
||||
@@ -5,5 +5,8 @@ class PretixApiConfig(AppConfig):
|
||||
name = 'pretix.api'
|
||||
label = 'pretixapi'
|
||||
|
||||
def ready(self):
|
||||
from . import signals, webhooks # noqa
|
||||
|
||||
|
||||
default_app_config = 'pretix.api.PretixApiConfig'
|
||||
|
||||
@@ -10,7 +10,10 @@ def custom_exception_handler(exc, context):
|
||||
if isinstance(exc, LockTimeoutException):
|
||||
response = Response(
|
||||
{'detail': 'The server was too busy to process your request. Please try again.'},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
headers={
|
||||
'Retry-After': 5
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 2.1.1 on 2018-11-07 10:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0102_auto_20181017_0024'),
|
||||
('pretixapi', '0002_auto_20180604_1120'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WebHook',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('enabled', models.BooleanField(default=True, verbose_name='Enable webhook')),
|
||||
('target_url', models.URLField(verbose_name='Target URL')),
|
||||
('all_events', models.BooleanField(default=False, verbose_name='All events (including newly created ones)')),
|
||||
('limit_events', models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events')),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Organizer')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WebHookCall',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('datetime', models.DateTimeField(auto_now_add=True)),
|
||||
('target_url', models.URLField()),
|
||||
('is_retry', models.BooleanField(default=False)),
|
||||
('execution_time', models.FloatField(null=True)),
|
||||
('return_code', models.PositiveIntegerField(default=0)),
|
||||
('payload', models.TextField()),
|
||||
('response_body', models.TextField()),
|
||||
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixapi.WebHook')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WebHookEventListener',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action_type', models.CharField(max_length=255)),
|
||||
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixapi.WebHook')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='webhookcall',
|
||||
name='success',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhook',
|
||||
name='all_events',
|
||||
field=models.BooleanField(default=True, verbose_name='All events (including newly created ones)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhook',
|
||||
name='organizer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhooks', to='pretixbase.Organizer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhookcall',
|
||||
name='webhook',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calls', to='pretixapi.WebHook'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhookeventlistener',
|
||||
name='webhook',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='listeners', to='pretixapi.WebHook'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='webhookcall',
|
||||
name='action_type',
|
||||
field=models.CharField(default='', max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -68,3 +68,41 @@ class OAuthRefreshToken(AbstractRefreshToken):
|
||||
OAuthAccessToken, on_delete=models.SET_NULL, blank=True, null=True,
|
||||
related_name="refresh_token"
|
||||
)
|
||||
|
||||
|
||||
class WebHook(models.Model):
|
||||
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
|
||||
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
|
||||
target_url = models.URLField(verbose_name=_("Target URL"))
|
||||
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
|
||||
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
|
||||
|
||||
@property
|
||||
def action_types(self):
|
||||
return [
|
||||
l.action_type for l in self.listeners.all()
|
||||
]
|
||||
|
||||
|
||||
class WebHookEventListener(models.Model):
|
||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='listeners')
|
||||
action_type = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
ordering = ("action_type",)
|
||||
|
||||
|
||||
class WebHookCall(models.Model):
|
||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
|
||||
datetime = models.DateTimeField(auto_now_add=True)
|
||||
target_url = models.URLField()
|
||||
action_type = models.CharField(max_length=255)
|
||||
is_retry = models.BooleanField(default=False)
|
||||
execution_time = models.FloatField(null=True)
|
||||
return_code = models.PositiveIntegerField(default=0)
|
||||
success = models.BooleanField(default=False)
|
||||
payload = models.TextField()
|
||||
response_body = models.TextField()
|
||||
|
||||
class Meta:
|
||||
ordering = ("-datetime",)
|
||||
|
||||
@@ -19,18 +19,19 @@ class CartPositionSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
|
||||
'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
|
||||
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
|
||||
'answers',)
|
||||
|
||||
|
||||
class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
answers = AnswerCreateSerializer(many=True, required=False)
|
||||
expires = serializers.DateTimeField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
|
||||
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'subevent', 'expires', 'includes_tax', 'answers',)
|
||||
|
||||
def create(self, validated_data):
|
||||
@@ -65,6 +66,11 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
quota.name
|
||||
)
|
||||
)
|
||||
attendee_name = validated_data.pop('attendee_name', '')
|
||||
if attendee_name and not validated_data.get('attendee_name_parts'):
|
||||
validated_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
|
||||
for answ_data in answers_data:
|
||||
@@ -118,4 +124,8 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
'You cannot specify a variation for this item.'
|
||||
)
|
||||
if data.get('attendee_name') and data.get('attendee_name_parts'):
|
||||
raise ValidationError(
|
||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||
)
|
||||
return data
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.functional import cached_property
|
||||
@@ -47,7 +48,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ('name', 'slug', 'live', 'currency', 'date_from',
|
||||
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
|
||||
'date_to', 'date_admission', 'is_public', 'presale_start',
|
||||
'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins')
|
||||
|
||||
@@ -95,7 +96,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
plugins_available = {
|
||||
p.module for p in get_all_plugins()
|
||||
p.module for p in get_all_plugins(self.instance)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
|
||||
@@ -108,7 +109,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
plugins = validated_data.pop('plugins', None)
|
||||
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
|
||||
event = super().create(validated_data)
|
||||
|
||||
# Meta data
|
||||
@@ -122,6 +123,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
# Plugins
|
||||
if plugins is not None:
|
||||
event.set_active_plugins(plugins)
|
||||
event.save(update_fields=['plugins'])
|
||||
|
||||
return event
|
||||
|
||||
|
||||
@@ -15,13 +15,20 @@ class I18nField(Field):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def to_representation(self, value):
|
||||
if value is None or value.data is None:
|
||||
if hasattr(value, 'data'):
|
||||
if isinstance(value.data, dict):
|
||||
return value.data
|
||||
elif value.data is None:
|
||||
return None
|
||||
else:
|
||||
return {
|
||||
settings.LANGUAGE_CODE: str(value.data)
|
||||
}
|
||||
elif value is None:
|
||||
return None
|
||||
if isinstance(value.data, dict):
|
||||
return value.data
|
||||
else:
|
||||
return {
|
||||
settings.LANGUAGE_CODE: str(value.data)
|
||||
settings.LANGUAGE_CODE: str(value)
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
|
||||
@@ -74,12 +74,12 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
fields = ('id', 'category', 'name', 'internal_name', 'active', 'description',
|
||||
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description',
|
||||
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission',
|
||||
'position', 'picture', 'available_from', 'available_until',
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
|
||||
'variations', 'addons', 'original_price', 'require_approval')
|
||||
'variations', 'addons', 'original_price', 'require_approval', 'generate_tickets')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
@@ -11,6 +11,8 @@ from rest_framework.relations import SlugRelatedField
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
||||
Question, QuestionAnswer,
|
||||
@@ -35,11 +37,12 @@ class CompatibleCountryField(serializers.Field):
|
||||
|
||||
class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
country = CompatibleCountryField(source='*')
|
||||
name = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('last_modified', 'is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||
'vat_id_validated', 'internal_reference')
|
||||
fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country',
|
||||
'vat_id', 'vat_id_validated', 'internal_reference')
|
||||
read_only_fields = ('last_modified', 'vat_id_validated')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -48,6 +51,15 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
v.required = False
|
||||
v.allow_blank = True
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('name') and data.get('name_parts'):
|
||||
raise ValidationError(
|
||||
{'name': ['Do not specify name if you specified name_parts.']}
|
||||
)
|
||||
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
|
||||
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
return data
|
||||
|
||||
|
||||
class AnswerQuestionIdentifierField(serializers.Field):
|
||||
def to_representation(self, instance: QuestionAnswer):
|
||||
@@ -103,9 +115,7 @@ class PositionDownloadsField(serializers.Field):
|
||||
if instance.order.status != Order.STATUS_PAID:
|
||||
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending:
|
||||
return []
|
||||
if instance.addon_to_id and not instance.order.event.settings.ticket_download_addons:
|
||||
return []
|
||||
if not instance.item.admission and not instance.order.event.settings.ticket_download_nonadm:
|
||||
if not instance.generate_ticket:
|
||||
return []
|
||||
|
||||
request = self.context['request']
|
||||
@@ -131,20 +141,21 @@ class PdfDataSerializer(serializers.Field):
|
||||
res = {}
|
||||
|
||||
ev = instance.subevent or instance.order.event
|
||||
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
|
||||
# we serialize a list.
|
||||
with language(instance.order.locale):
|
||||
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
|
||||
# we serialize a list.
|
||||
|
||||
if 'vars' not in self.context:
|
||||
self.context['vars'] = get_variables(self.context['request'].event)
|
||||
if 'vars' not in self.context:
|
||||
self.context['vars'] = get_variables(self.context['request'].event)
|
||||
|
||||
for k, f in self.context['vars'].items():
|
||||
res[k] = f['evaluate'](instance, instance.order, ev)
|
||||
for k, f in self.context['vars'].items():
|
||||
res[k] = f['evaluate'](instance, instance.order, ev)
|
||||
|
||||
if not hasattr(ev, '_cached_meta_data'):
|
||||
ev._cached_meta_data = ev.meta_data
|
||||
if not hasattr(ev, '_cached_meta_data'):
|
||||
ev._cached_meta_data = ev.meta_data
|
||||
|
||||
for k, v in ev._cached_meta_data.items():
|
||||
res['meta:' + k] = v
|
||||
for k, v in ev._cached_meta_data.items():
|
||||
res['meta:' + k] = v
|
||||
|
||||
return res
|
||||
|
||||
@@ -158,9 +169,9 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
|
||||
'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'downloads',
|
||||
'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data')
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -220,9 +231,9 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
fields = ('code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
||||
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval')
|
||||
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -305,14 +316,15 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
answers = AnswerCreateSerializer(many=True, required=False)
|
||||
addon_to = serializers.IntegerField(required=False, allow_null=True)
|
||||
secret = serializers.CharField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
|
||||
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'secret', 'addon_to', 'subevent', 'answers')
|
||||
|
||||
def validate_secret(self, secret):
|
||||
if secret and OrderPosition.objects.filter(order__event=self.context['event'], secret=secret).exists():
|
||||
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
|
||||
raise ValidationError(
|
||||
'You cannot assign a position secret that already exists.'
|
||||
)
|
||||
@@ -359,6 +371,12 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
{'variation': ['You cannot specify a variation for this item.']}
|
||||
)
|
||||
if data.get('attendee_name') and data.get('attendee_name_parts'):
|
||||
raise ValidationError(
|
||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||
)
|
||||
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
|
||||
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
return data
|
||||
|
||||
|
||||
@@ -395,7 +413,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment',
|
||||
fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
@@ -403,6 +421,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError('The given payment provider is not known.')
|
||||
return pp
|
||||
|
||||
def validate_sales_channel(self, channel):
|
||||
if channel not in get_all_sales_channels():
|
||||
raise ValidationError('Unknown sales channel.')
|
||||
return channel
|
||||
|
||||
def validate_code(self, code):
|
||||
if code and Order.objects.filter(event__organizer=self.context['event'].organizer, code=code).exists():
|
||||
raise ValidationError(
|
||||
@@ -464,7 +487,13 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
payment_info = validated_data.pop('payment_info', '{}')
|
||||
|
||||
if 'invoice_address' in validated_data:
|
||||
ia = InvoiceAddress(**validated_data.pop('invoice_address'))
|
||||
iadata = validated_data.pop('invoice_address')
|
||||
name = iadata.pop('name', '')
|
||||
if name and not iadata.get('name_parts'):
|
||||
iadata['name_parts'] = {
|
||||
'_legacy': name
|
||||
}
|
||||
ia = InvoiceAddress(**iadata)
|
||||
else:
|
||||
ia = None
|
||||
|
||||
@@ -528,7 +557,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
order.payments.create(
|
||||
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
payment_date=now()
|
||||
)
|
||||
elif payment_provider == "free" and order.total != Decimal('0.00'):
|
||||
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
|
||||
@@ -555,6 +585,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
for pos_data in positions_data:
|
||||
answers_data = pos_data.pop('answers', [])
|
||||
addon_to = pos_data.pop('addon_to', None)
|
||||
attendee_name = pos_data.pop('attendee_name', '')
|
||||
if attendee_name and not pos_data.get('attendee_name_parts'):
|
||||
pos_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
pos = OrderPosition(**pos_data)
|
||||
pos.order = order
|
||||
pos._calculate_tax()
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import Voucher
|
||||
|
||||
|
||||
class VoucherListSerializer(serializers.ListSerializer):
|
||||
def create(self, validated_data):
|
||||
codes = set()
|
||||
errs = []
|
||||
err = False
|
||||
for voucher_data in validated_data:
|
||||
if voucher_data['code'] in codes:
|
||||
err = True
|
||||
errs.append({'code': ['Duplicate voucher code in request.']})
|
||||
else:
|
||||
codes.add(voucher_data['code'])
|
||||
errs.append({})
|
||||
if err:
|
||||
raise ValidationError(errs)
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class VoucherSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Voucher
|
||||
@@ -9,6 +29,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
|
||||
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
|
||||
'tag', 'comment', 'subevent')
|
||||
read_only_fields = ('id', 'redeemed')
|
||||
list_serializer_class = VoucherListSerializer
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
71
src/pretix/api/serializers/webhooks.py
Normal file
71
src/pretix/api/serializers/webhooks.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.models import WebHook
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.webhooks import get_all_webhook_events
|
||||
from pretix.base.models import Event
|
||||
|
||||
|
||||
class EventRelatedField(serializers.SlugRelatedField):
|
||||
def get_queryset(self):
|
||||
return self.context['organizer'].events.all()
|
||||
|
||||
|
||||
class ActionTypesField(serializers.Field):
|
||||
def to_representation(self, instance: WebHook):
|
||||
return instance.action_types
|
||||
|
||||
def to_internal_value(self, data):
|
||||
types = get_all_webhook_events()
|
||||
for d in data:
|
||||
if d not in types:
|
||||
raise ValidationError('Invalid action type "%s".' % d)
|
||||
return {'action_types': data}
|
||||
|
||||
|
||||
class WebHookSerializer(I18nAwareModelSerializer):
|
||||
limit_events = EventRelatedField(
|
||||
slug_field='slug',
|
||||
queryset=Event.objects.none(),
|
||||
many=True
|
||||
)
|
||||
action_types = ActionTypesField(source='*')
|
||||
|
||||
class Meta:
|
||||
model = WebHook
|
||||
fields = ('id', 'enabled', 'target_url', 'all_events', 'limit_events', 'action_types')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
for event in full_data.get('limit_events'):
|
||||
if self.context['organizer'] != event.organizer:
|
||||
raise ValidationError('One or more events do not belong to this organizer.')
|
||||
|
||||
if full_data.get('limit_events') and full_data.get('all_events'):
|
||||
raise ValidationError('You can set either limit_events or all_events.')
|
||||
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
action_types = validated_data.pop('action_types')
|
||||
inst = super().create(validated_data)
|
||||
for l in action_types:
|
||||
inst.listeners.create(action_type=l)
|
||||
return inst
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
action_types = validated_data.pop('action_types', None)
|
||||
instance = super().update(instance, validated_data)
|
||||
if action_types is not None:
|
||||
current_listeners = set(instance.listeners.values_list('action_type', flat=True))
|
||||
new_listeners = set(action_types)
|
||||
for l in current_listeners - new_listeners:
|
||||
instance.listeners.filter(action_type=l).delete()
|
||||
for l in new_listeners - current_listeners:
|
||||
instance.listeners.create(action_type=l)
|
||||
return instance
|
||||
21
src/pretix/api/signals.py
Normal file
21
src/pretix/api/signals.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.dispatch import Signal, receiver
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix.api.models import WebHookCall
|
||||
from pretix.base.signals import periodic_task
|
||||
|
||||
register_webhook_events = Signal(
|
||||
providing_args=[]
|
||||
)
|
||||
"""
|
||||
This signal is sent out to get all known webhook events. Receivers should return an
|
||||
instance of a subclass of pretix.api.webhooks.WebhookEvent or a list of such
|
||||
instances.
|
||||
"""
|
||||
|
||||
|
||||
@receiver(periodic_task)
|
||||
def cleanup_webhook_logs(sender, **kwargs):
|
||||
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()
|
||||
@@ -7,8 +7,8 @@ from rest_framework import routers
|
||||
from pretix.api.views import cart
|
||||
|
||||
from .views import (
|
||||
checkin, device, event, item, oauth, order, organizer, voucher,
|
||||
waitinglist,
|
||||
checkin, device, event, item, oauth, order, organizer, user, voucher,
|
||||
waitinglist, webhooks,
|
||||
)
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
@@ -17,6 +17,7 @@ router.register(r'organizers', organizer.OrganizerViewSet)
|
||||
orga_router = routers.DefaultRouter()
|
||||
orga_router.register(r'events', event.EventViewSet)
|
||||
orga_router.register(r'subevents', event.SubEventViewSet)
|
||||
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
|
||||
|
||||
event_router = routers.DefaultRouter()
|
||||
event_router.register(r'subevents', event.SubEventViewSet)
|
||||
@@ -71,4 +72,5 @@ urlpatterns = [
|
||||
url(r"^device/update$", device.UpdateView.as_view(), name="device.update"),
|
||||
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
|
||||
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
|
||||
url(r"^me$", user.MeView.as_view(), name="user.me"),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count, F, Max, OuterRef, Prefetch, Subquery
|
||||
from django.db.models import Count, F, Max, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -16,7 +16,9 @@ from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import OrderPositionSerializer
|
||||
from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.models import Checkin, CheckinList, Order, OrderPosition
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Event, Order, OrderPosition,
|
||||
)
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, perform_checkin,
|
||||
)
|
||||
@@ -154,7 +156,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPositionSerializer
|
||||
queryset = OrderPosition.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
|
||||
ordering = ('attendee_name', 'positionid')
|
||||
ordering = ('attendee_name_cached', 'positionid')
|
||||
ordering_fields = (
|
||||
'order__code', 'order__datetime', 'positionid', 'attendee_name',
|
||||
'last_checked_in', 'order__email',
|
||||
@@ -162,11 +164,11 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ordering_custom = {
|
||||
'attendee_name': {
|
||||
'_order': F('display_name').asc(nulls_first=True),
|
||||
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
'-attendee_name': {
|
||||
'_order': F('display_name').desc(nulls_last=True),
|
||||
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
'last_checked_in': {
|
||||
'_order': FixedOrderBy(F('last_checked_in'), nulls_first=True),
|
||||
@@ -201,12 +203,39 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
subevent=self.checkinlist.subevent
|
||||
).annotate(
|
||||
last_checked_in=Subquery(cqs)
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
),
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
Event.objects.select_related('organizer')
|
||||
),
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to'
|
||||
)
|
||||
).select_related('item', 'variation', 'order', 'addon_to')
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address')
|
||||
|
||||
if not self.checkinlist.all_products:
|
||||
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
|
||||
@@ -244,11 +273,15 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ignore_unpaid=ignore_unpaid,
|
||||
nonce=nonce,
|
||||
datetime=dt,
|
||||
questions_supported=self.request.data.get('questions_supported', True)
|
||||
questions_supported=self.request.data.get('questions_supported', True),
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
'status': 'incomplete',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data,
|
||||
'questions': [
|
||||
QuestionSerializer(q).data for q in e.questions
|
||||
]
|
||||
@@ -256,9 +289,21 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
except CheckInError as e:
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': e.code
|
||||
'reason': e.code,
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=400)
|
||||
else:
|
||||
return Response({
|
||||
'status': 'ok',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=201)
|
||||
|
||||
def get_object(self):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
if self.kwargs['pk'].isnumeric():
|
||||
obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
||||
else:
|
||||
obj = get_object_or_404(queryset, secret=self.kwargs['pk'])
|
||||
return obj
|
||||
|
||||
@@ -83,6 +83,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
self.get_object().cartposition_set.all().delete()
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import django_filters
|
||||
import pytz
|
||||
from django.db import transaction
|
||||
from django.db.models import Prefetch, Q
|
||||
from django.db.models.functions import Concat
|
||||
from django.db.models import F, Prefetch, Q
|
||||
from django.db.models.functions import Coalesce, Concat
|
||||
from django.http import FileResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import make_aware, now
|
||||
@@ -15,7 +16,7 @@ from rest_framework.exceptions import (
|
||||
APIException, NotFound, PermissionDenied, ValidationError,
|
||||
)
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.mixins import CreateModelMixin
|
||||
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
@@ -25,8 +26,8 @@ from pretix.api.serializers.order import (
|
||||
OrderRefundSerializer, OrderSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Device, Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota,
|
||||
TeamAPIToken,
|
||||
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, Order,
|
||||
OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.services.invoices import (
|
||||
@@ -38,9 +39,7 @@ from pretix.base.services.orders import (
|
||||
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
|
||||
extend_order, mark_order_expired, mark_order_refunded,
|
||||
)
|
||||
from pretix.base.services.tickets import (
|
||||
get_cachedticket_for_order, get_cachedticket_for_position,
|
||||
)
|
||||
from pretix.base.services.tickets import generate
|
||||
from pretix.base.signals import order_placed, register_ticket_outputs
|
||||
|
||||
|
||||
@@ -52,15 +51,15 @@ class OrderFilter(FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['code', 'status', 'email', 'locale', 'require_approval']
|
||||
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
|
||||
|
||||
|
||||
class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderSerializer
|
||||
queryset = Order.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering = ('datetime',)
|
||||
ordering_fields = ('datetime', 'code', 'status')
|
||||
ordering_fields = ('datetime', 'code', 'status', 'last_modified')
|
||||
filterset_class = OrderFilter
|
||||
lookup_field = 'code'
|
||||
permission = 'can_view_orders'
|
||||
@@ -84,6 +83,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
'positions',
|
||||
OrderPosition.objects.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'item__category', 'addon_to',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
)
|
||||
)
|
||||
@@ -130,9 +130,11 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
if order.status != Order.STATUS_PAID:
|
||||
raise PermissionDenied("Downloads are not available for unpaid orders.")
|
||||
|
||||
ct = get_cachedticket_for_order(order, provider.identifier)
|
||||
|
||||
if not ct.file:
|
||||
ct = CachedCombinedTicket.objects.filter(
|
||||
order=order, provider=provider.identifier, file__isnull=False
|
||||
).last()
|
||||
if not ct or not ct.file:
|
||||
generate.apply_async(args=('order', order.pk, provider.identifier))
|
||||
raise RetryException()
|
||||
else:
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
@@ -186,6 +188,12 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
@detail_route(methods=['POST'])
|
||||
def mark_canceled(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
cancellation_fee = request.data.get('cancellation_fee', None)
|
||||
if cancellation_fee:
|
||||
try:
|
||||
cancellation_fee = float(Decimal(cancellation_fee))
|
||||
except:
|
||||
cancellation_fee = None
|
||||
|
||||
order = self.get_object()
|
||||
if not order.cancel_allowed():
|
||||
@@ -194,14 +202,21 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
cancel_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
|
||||
device=request.auth if isinstance(request.auth, Device) else None,
|
||||
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
||||
send_mail=send_mail
|
||||
)
|
||||
try:
|
||||
cancel_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
|
||||
device=request.auth if isinstance(request.auth, Device) else None,
|
||||
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
||||
send_mail=send_mail,
|
||||
cancellation_fee=cancellation_fee
|
||||
)
|
||||
except OrderError as e:
|
||||
return Response(
|
||||
{'detail': str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@@ -251,7 +266,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
|
||||
order.status = Order.STATUS_PENDING
|
||||
order.save()
|
||||
order.save(update_fields=['status'])
|
||||
order.log_action(
|
||||
'pretix.event.order.unpaid',
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
@@ -363,6 +378,13 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.testmode:
|
||||
raise PermissionDenied('Only test mode orders can be deleted.')
|
||||
|
||||
with transaction.atomic():
|
||||
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
|
||||
|
||||
class OrderPositionFilter(FilterSet):
|
||||
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
|
||||
@@ -373,17 +395,18 @@ class OrderPositionFilter(FilterSet):
|
||||
def search_qs(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
Q(secret__istartswith=value)
|
||||
| Q(attendee_name__icontains=value)
|
||||
| Q(addon_to__attendee_name__icontains=value)
|
||||
| Q(attendee_name_cached__icontains=value)
|
||||
| Q(addon_to__attendee_name_cached__icontains=value)
|
||||
| Q(order__code__istartswith=value)
|
||||
| Q(order__invoice_address__name__icontains=value)
|
||||
| Q(order__invoice_address__name_cached__icontains=value)
|
||||
| Q(order__email__icontains=value)
|
||||
)
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
return queryset.filter(checkins__isnull=not value)
|
||||
|
||||
def attendee_name_qs(self, queryset, name, value):
|
||||
return queryset.filter(Q(attendee_name__iexact=value) | Q(addon_to__attendee_name__iexact=value))
|
||||
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
@@ -409,13 +432,45 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
filterset_class = OrderPositionFilter
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
ordering_custom = {
|
||||
'attendee_name': {
|
||||
'_order': F('display_name').asc(nulls_first=True),
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
'-attendee_name': {
|
||||
'_order': F('display_name').asc(nulls_last=True),
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question'
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer'
|
||||
)
|
||||
qs = OrderPosition.objects.filter(order__event=self.request.event)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
Event.objects.select_related('organizer')
|
||||
),
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to'
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question'
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer'
|
||||
)
|
||||
return qs
|
||||
|
||||
def _get_output_provider(self, identifier):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
@@ -432,14 +487,14 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
|
||||
if pos.order.status != Order.STATUS_PAID:
|
||||
raise PermissionDenied("Downloads are not available for unpaid orders.")
|
||||
if pos.addon_to_id and not request.event.settings.ticket_download_addons:
|
||||
raise PermissionDenied("Downloads are not enabled for add-on products.")
|
||||
if not pos.item.admission and not request.event.settings.ticket_download_nonadm:
|
||||
raise PermissionDenied("Downloads are not enabled for non-admission products.")
|
||||
if not pos.generate_ticket:
|
||||
raise PermissionDenied("Downloads are not enabled for this product.")
|
||||
|
||||
ct = get_cachedticket_for_position(pos, provider.identifier)
|
||||
|
||||
if not ct.file:
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=pos, provider=provider.identifier, file__isnull=False
|
||||
).last()
|
||||
if not ct or not ct.file:
|
||||
generate.apply_async(args=('orderposition', pos.pk, provider.identifier))
|
||||
raise RetryException()
|
||||
else:
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
@@ -503,7 +558,10 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
||||
request.data.get('amount', str(payment.amount))
|
||||
)
|
||||
mark_refunded = request.data.get('mark_refunded', False)
|
||||
if 'mark_refunded' in request.data:
|
||||
mark_refunded = request.data.get('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.get('mark_canceled', False)
|
||||
|
||||
if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED:
|
||||
return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -556,7 +614,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
payment.order.event.subevents.filter(
|
||||
id__in=payment.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
payment.order.save()
|
||||
payment.order.save(update_fields=['status', 'expires'])
|
||||
return Response(OrderRefundSerializer(r).data, status=status.HTTP_200_OK)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@@ -612,17 +670,21 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
if request.data.get('mark_refunded', False):
|
||||
if 'mark_refunded' in request.data:
|
||||
mark_refunded = request.data.get('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.get('mark_canceled', False)
|
||||
if mark_refunded:
|
||||
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth)
|
||||
else:
|
||||
elif not (refund.order.status == Order.STATUS_PAID and refund.order.pending_sum <= 0):
|
||||
refund.order.status = Order.STATUS_PENDING
|
||||
refund.order.set_expires(
|
||||
now(),
|
||||
refund.order.event.subevents.filter(
|
||||
id__in=refund.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
refund.order.save()
|
||||
refund.order.save(update_fields=['status', 'expires'])
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@@ -641,7 +703,10 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
return ctx
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
mark_refunded = request.data.pop('mark_refunded', False)
|
||||
if 'mark_refunded' in request.data:
|
||||
mark_refunded = request.data.pop('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.pop('mark_canceled', False)
|
||||
serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
@@ -758,7 +823,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||
else:
|
||||
c = generate_cancellation(inv)
|
||||
if inv.order.status not in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED):
|
||||
if inv.order.status != Order.STATUS_CANCELED:
|
||||
inv = generate_invoice(inv.order)
|
||||
else:
|
||||
inv = c
|
||||
|
||||
16
src/pretix/api/views/user.py
Normal file
16
src/pretix/api/views/user.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
|
||||
class MeView(APIView):
|
||||
authentication_classes = (SessionAuthentication, OAuth2Authentication)
|
||||
|
||||
def get(self, request, format=None):
|
||||
return Response({
|
||||
'email': request.user.email,
|
||||
'fullname': request.user.fullname,
|
||||
'locale': request.user.locale,
|
||||
'timezone': request.user.timezone
|
||||
})
|
||||
@@ -1,11 +1,16 @@
|
||||
import contextlib
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import (
|
||||
BooleanFilter, DjangoFilterBackend, FilterSet,
|
||||
)
|
||||
from rest_framework import viewsets
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import list_route
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.voucher import VoucherSerializer
|
||||
from pretix.base.models import Voucher
|
||||
@@ -41,8 +46,29 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
def get_queryset(self):
|
||||
return self.request.event.vouchers.all()
|
||||
|
||||
def _predict_quota_check(self, data, instance):
|
||||
# This method predicts if Voucher.clean_quota_needs_checking
|
||||
# *migh* later require a quota check. It is only approximate
|
||||
# and returns True a little too often. The point is to avoid
|
||||
# locks when we know we won't need them.
|
||||
if 'allow_ignore_quota' in data and data.get('allow_ignore_quota'):
|
||||
return False
|
||||
if instance and 'allow_ignore_quota' not in data and instance.allow_ignore_quota:
|
||||
return False
|
||||
|
||||
if 'block_quota' in data and not data.get('block_quota'):
|
||||
return False
|
||||
if instance and 'block_quota' not in data and not instance.block_quota:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
with request.event.lock():
|
||||
if self._predict_quota_check(request.data, None):
|
||||
lockfn = request.event.lock
|
||||
else:
|
||||
lockfn = contextlib.suppress # noop context manager
|
||||
with lockfn():
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -60,7 +86,11 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
return ctx
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
with request.event.lock():
|
||||
if self._predict_quota_check(request.data, self.get_object()):
|
||||
lockfn = request.event.lock
|
||||
else:
|
||||
lockfn = contextlib.suppress # noop context manager
|
||||
with lockfn():
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -82,3 +112,24 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@list_route(methods=['POST'])
|
||||
def batch_create(self, request, *args, **kwargs):
|
||||
if any(self._predict_quota_check(d, None) for d in request.data):
|
||||
lockfn = request.event.lock
|
||||
else:
|
||||
lockfn = contextlib.suppress # noop context manager
|
||||
with lockfn():
|
||||
serializer = self.get_serializer(data=request.data, many=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
serializer.save(event=self.request.event)
|
||||
for i, v in enumerate(serializer.instance):
|
||||
v.log_action(
|
||||
'pretix.voucher.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data[i]
|
||||
)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
49
src/pretix/api/views/webhooks.py
Normal file
49
src/pretix/api/views/webhooks.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from rest_framework import viewsets
|
||||
|
||||
from pretix.api.models import WebHook
|
||||
from pretix.api.serializers.webhooks import WebHookSerializer
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
|
||||
class WebHookViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = WebHookSerializer
|
||||
queryset = WebHook.objects.none()
|
||||
permission = 'can_change_organizer_settings'
|
||||
write_permission = 'can_change_organizer_settings'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.webhooks.prefetch_related('listeners')
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
self.request.organizer.log_action(
|
||||
'pretix.webhook.created',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
self.request.organizer.log_action(
|
||||
'pretix.webhook.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
|
||||
)
|
||||
return inst
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
self.request.organizer.log_action(
|
||||
'pretix.webhook.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'id': instance.pk, 'enabled': False}
|
||||
)
|
||||
instance.enabled = False
|
||||
instance.save(update_fields=['enabled'])
|
||||
253
src/pretix/api/webhooks.py
Normal file
253
src/pretix/api/webhooks.py
Normal file
@@ -0,0 +1,253 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
import requests
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from requests import RequestException
|
||||
|
||||
from pretix.api.models import WebHook, WebHookCall, WebHookEventListener
|
||||
from pretix.api.signals import register_webhook_events
|
||||
from pretix.base.models import LogEntry
|
||||
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
||||
from pretix.celery_app import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_ALL_EVENTS = None
|
||||
|
||||
|
||||
class WebhookEvent:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return '<WebhookEvent: {}>'.format(self.action_type)
|
||||
|
||||
@property
|
||||
def action_type(self) -> str:
|
||||
"""
|
||||
The action_type string that this notification handles, for example
|
||||
``"pretix.event.order.paid"``. Only one notification type should be registered
|
||||
per action type.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@property
|
||||
def verbose_name(self) -> str:
|
||||
"""
|
||||
A human-readable name of this notification type.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
def build_payload(self, logentry: LogEntry) -> dict:
|
||||
"""
|
||||
This is the main function that you should override. It is supposed to turn a log entry
|
||||
object into a dictionary that can be used as the webhook payload.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
|
||||
def get_all_webhook_events():
|
||||
global _ALL_EVENTS
|
||||
|
||||
if _ALL_EVENTS:
|
||||
return _ALL_EVENTS
|
||||
|
||||
types = OrderedDict()
|
||||
for recv, ret in register_webhook_events.send(None):
|
||||
if isinstance(ret, (list, tuple)):
|
||||
for r in ret:
|
||||
types[r.action_type] = r
|
||||
else:
|
||||
types[ret.action_type] = ret
|
||||
_ALL_EVENTS = types
|
||||
return types
|
||||
|
||||
|
||||
class ParametrizedOrderWebhookEvent(WebhookEvent):
|
||||
def __init__(self, action_type, verbose_name):
|
||||
self._action_type = action_type
|
||||
self._verbose_name = verbose_name
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def action_type(self):
|
||||
return self._action_type
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return self._verbose_name
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
order = logentry.content_object
|
||||
|
||||
return {
|
||||
'notification_id': logentry.pk,
|
||||
'organizer': order.event.organizer.slug,
|
||||
'event': order.event.slug,
|
||||
'code': order.code,
|
||||
'action': logentry.action_type,
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
d = super().build_payload(logentry)
|
||||
d['orderposition_id'] = logentry.parsed_data.get('position')
|
||||
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
|
||||
d['checkin_list'] = logentry.parsed_data.get('list')
|
||||
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
|
||||
return d
|
||||
|
||||
|
||||
@receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events")
|
||||
def register_default_webhook_events(sender, **kwargs):
|
||||
return (
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.placed',
|
||||
_('New order placed'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.placed.require_approval',
|
||||
_('New order requires approval'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.paid',
|
||||
_('Order marked as paid'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.canceled',
|
||||
_('Order canceled'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.expired',
|
||||
_('Order expired'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.modified',
|
||||
_('Order information changed'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.contact.changed',
|
||||
_('Order contact address changed'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.changed.*',
|
||||
_('Order changed'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.refund.created.externally',
|
||||
_('External refund of payment'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.approved',
|
||||
_('Order approved'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.denied',
|
||||
_('Order denied'),
|
||||
),
|
||||
ParametrizedOrderPositionWebhookEvent(
|
||||
'pretix.event.checkin',
|
||||
_('Ticket checked in'),
|
||||
),
|
||||
ParametrizedOrderPositionWebhookEvent(
|
||||
'pretix.event.checkin.reverted',
|
||||
_('Ticket check-in reverted'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.task(base=TransactionAwareTask)
|
||||
def notify_webhooks(logentry_id: int):
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
|
||||
if not logentry.organizer:
|
||||
return # We need to know the organizer
|
||||
|
||||
types = get_all_webhook_events()
|
||||
notification_type = None
|
||||
typepath = logentry.action_type
|
||||
while not notification_type and '.' in typepath:
|
||||
notification_type = types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
|
||||
typepath = typepath.rsplit('.', 1)[0]
|
||||
|
||||
if not notification_type:
|
||||
return # Ignore, no webhooks for this event type
|
||||
|
||||
# All webhooks that registered for this notification
|
||||
event_listener = WebHookEventListener.objects.filter(
|
||||
webhook=OuterRef('pk'),
|
||||
action_type=notification_type.action_type
|
||||
)
|
||||
|
||||
webhooks = WebHook.objects.annotate(has_el=Exists(event_listener)).filter(
|
||||
organizer=logentry.organizer,
|
||||
has_el=True,
|
||||
enabled=True
|
||||
)
|
||||
if logentry.event_id:
|
||||
webhooks = webhooks.filter(
|
||||
Q(all_events=True) | Q(limit_events__pk=logentry.event_id)
|
||||
)
|
||||
|
||||
for wh in webhooks:
|
||||
send_webhook.apply_async(args=(logentry_id, notification_type.action_type, wh.pk))
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=9)
|
||||
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
|
||||
# 9 retries with 2**(2*x) timing is roughly 72 hours
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
webhook = WebHook.objects.get(id=webhook_id)
|
||||
|
||||
types = get_all_webhook_events()
|
||||
event_type = types.get(action_type)
|
||||
if not event_type or not webhook.enabled:
|
||||
return # Ignore, e.g. plugin not installed
|
||||
|
||||
payload = event_type.build_payload(logentry)
|
||||
t = time.time()
|
||||
|
||||
try:
|
||||
try:
|
||||
resp = requests.post(
|
||||
webhook.target_url,
|
||||
json=payload,
|
||||
allow_redirects=False
|
||||
)
|
||||
WebHookCall.objects.create(
|
||||
webhook=webhook,
|
||||
action_type=logentry.action_type,
|
||||
target_url=webhook.target_url,
|
||||
is_retry=self.request.retries > 0,
|
||||
execution_time=time.time() - t,
|
||||
return_code=resp.status_code,
|
||||
payload=json.dumps(payload),
|
||||
response_body=resp.text[:1024 * 1024],
|
||||
success=200 <= resp.status_code <= 299
|
||||
)
|
||||
if resp.status_code == 410:
|
||||
webhook.enabled = False
|
||||
webhook.save()
|
||||
elif resp.status_code > 299:
|
||||
raise self.retry(countdown=2 ** (self.request.retries * 2))
|
||||
except RequestException as e:
|
||||
WebHookCall.objects.create(
|
||||
webhook=webhook,
|
||||
action_type=logentry.action_type,
|
||||
target_url=webhook.target_url,
|
||||
is_retry=self.request.retries > 0,
|
||||
execution_time=time.time() - t,
|
||||
return_code=0,
|
||||
payload=json.dumps(payload),
|
||||
response_body=str(e)[:1024 * 1024]
|
||||
)
|
||||
raise self.retry(countdown=2 ** (self.request.retries * 2))
|
||||
except MaxRetriesExceededError:
|
||||
pass
|
||||
66
src/pretix/base/channels.py
Normal file
66
src/pretix/base/channels.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.signals import register_sales_channels
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_ALL_CHANNELS = None
|
||||
|
||||
|
||||
class SalesChannel:
|
||||
def __repr__(self):
|
||||
return '<SalesChannel: {}>'.format(self.identifier)
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""
|
||||
The internal identifier of this sales channel.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@property
|
||||
def verbose_name(self) -> str:
|
||||
"""
|
||||
A human-readable name of this sales channel.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""
|
||||
The name of a Font Awesome icon to represent this channel
|
||||
"""
|
||||
return "circle"
|
||||
|
||||
|
||||
def get_all_sales_channels():
|
||||
global _ALL_CHANNELS
|
||||
|
||||
if _ALL_CHANNELS:
|
||||
return _ALL_CHANNELS
|
||||
|
||||
types = OrderedDict()
|
||||
for recv, ret in register_sales_channels.send(None):
|
||||
if isinstance(ret, (list, tuple)):
|
||||
for r in ret:
|
||||
types[r.identifier] = r
|
||||
else:
|
||||
types[ret.identifier] = ret
|
||||
_ALL_CHANNELS = types
|
||||
return types
|
||||
|
||||
|
||||
class WebshopSalesChannel(SalesChannel):
|
||||
identifier = "web"
|
||||
verbose_name = _('Online shop')
|
||||
icon = "globe"
|
||||
|
||||
|
||||
@receiver(register_sales_channels, dispatch_uid="base_register_default_sales_channels")
|
||||
def base_sales_channels(sender, **kwargs):
|
||||
return (
|
||||
WebshopSalesChannel(),
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from smtplib import SMTPRecipientsRefused, SMTPSenderRefused
|
||||
from smtplib import SMTPResponseException
|
||||
|
||||
import bleach
|
||||
import markdown
|
||||
@@ -23,16 +23,14 @@ class CustomSMTPBackend(EmailBackend):
|
||||
try:
|
||||
self.open()
|
||||
self.connection.ehlo_or_helo_if_needed()
|
||||
self.connection.rcpt("test@example.org")
|
||||
(code, resp) = self.connection.mail(from_addr, [])
|
||||
if code != 250:
|
||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||
raise SMTPSenderRefused(code, resp, from_addr)
|
||||
senderrs = {}
|
||||
(code, resp) = self.connection.rcpt('test@example.com')
|
||||
raise SMTPResponseException(code, resp)
|
||||
(code, resp) = self.connection.rcpt('testdummy@pretix.eu')
|
||||
if (code != 250) and (code != 251):
|
||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||
raise SMTPRecipientsRefused(senderrs)
|
||||
raise SMTPResponseException(code, resp)
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
@@ -97,7 +95,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
|
||||
@property
|
||||
def template_name(self):
|
||||
raise NotImplemented
|
||||
raise NotImplementedError()
|
||||
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order) -> str:
|
||||
body_md = bleach.linkify(markdown_compile(plain_body))
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import io
|
||||
import tempfile
|
||||
from collections import OrderedDict
|
||||
from typing import Tuple
|
||||
|
||||
from defusedcsv import csv
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext, ugettext_lazy as _
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell.cell import KNOWN_TYPES
|
||||
|
||||
|
||||
class BaseExporter:
|
||||
"""
|
||||
@@ -55,7 +64,7 @@ class BaseExporter:
|
||||
"""
|
||||
return {}
|
||||
|
||||
def render(self, form_data: dict) -> Tuple[str, str, str]:
|
||||
def render(self, form_data: dict) -> Tuple[str, str, bytes]:
|
||||
"""
|
||||
Render the exported file and return a tuple consisting of a filename, a file type
|
||||
and file content.
|
||||
@@ -69,3 +78,138 @@ class BaseExporter:
|
||||
tasks.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
|
||||
class ListExporter(BaseExporter):
|
||||
|
||||
@property
|
||||
def export_form_fields(self) -> dict:
|
||||
ff = OrderedDict(
|
||||
[
|
||||
('_format',
|
||||
forms.ChoiceField(
|
||||
label=_('Export format'),
|
||||
choices=(
|
||||
('xlsx', _('Excel (.xlsx)')),
|
||||
('default', _('CSV (with commas)')),
|
||||
('excel', _('CSV (Excel-style)')),
|
||||
('semicolon', _('CSV (with semicolons)')),
|
||||
),
|
||||
)),
|
||||
]
|
||||
)
|
||||
ff.update(self.additional_form_fields)
|
||||
return ff
|
||||
|
||||
@property
|
||||
def additional_form_fields(self) -> dict:
|
||||
return {}
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
def get_filename(self):
|
||||
return 'export.csv'
|
||||
|
||||
def _render_csv(self, form_data, **kwargs):
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, **kwargs)
|
||||
for line in self.iterate_list(form_data):
|
||||
writer.writerow(line)
|
||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
def _render_xlsx(self, form_data):
|
||||
wb = Workbook()
|
||||
ws = wb.get_active_sheet()
|
||||
try:
|
||||
ws.title = str(self.verbose_name)
|
||||
except:
|
||||
pass
|
||||
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
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
|
||||
wb.save(f.name)
|
||||
f.seek(0)
|
||||
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
|
||||
|
||||
def render(self, form_data: dict) -> Tuple[str, str, bytes]:
|
||||
if form_data.get('_format') == 'xlsx':
|
||||
return self._render_xlsx(form_data)
|
||||
elif form_data.get('_format') == 'default':
|
||||
return self._render_csv(form_data, quoting=csv.QUOTE_NONNUMERIC, delimiter=',')
|
||||
elif form_data.get('_format') == 'csv-excel':
|
||||
return self._render_csv(form_data, dialect='excel')
|
||||
elif form_data.get('_format') == 'semicolon':
|
||||
return self._render_csv(form_data, dialect='excel', delimiter=';')
|
||||
|
||||
|
||||
class MultiSheetListExporter(ListExporter):
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def export_form_fields(self) -> dict:
|
||||
choices = [
|
||||
('xlsx', _('Combined Excel (.xlsx)')),
|
||||
]
|
||||
for s, l in self.sheets:
|
||||
choices += [
|
||||
(s + ':default', str(l) + ' – ' + ugettext('CSV (with commas)')),
|
||||
(s + ':excel', str(l) + ' – ' + ugettext('CSV (Excel-style)')),
|
||||
(s + ':semicolon', str(l) + ' – ' + ugettext('CSV (with semicolons)')),
|
||||
]
|
||||
ff = OrderedDict(
|
||||
[
|
||||
('_format',
|
||||
forms.ChoiceField(
|
||||
label=_('Export format'),
|
||||
choices=choices,
|
||||
)),
|
||||
]
|
||||
)
|
||||
ff.update(self.additional_form_fields)
|
||||
return ff
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
pass
|
||||
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
def _render_sheet_csv(self, form_data, sheet, **kwargs):
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, **kwargs)
|
||||
for line in self.iterate_sheet(form_data, sheet):
|
||||
writer.writerow(line)
|
||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
def _render_xlsx(self, form_data):
|
||||
wb = Workbook()
|
||||
ws = wb.get_active_sheet()
|
||||
wb.remove(ws)
|
||||
for s, l in self.sheets:
|
||||
ws = wb.create_sheet(str(l))
|
||||
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
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
|
||||
wb.save(f.name)
|
||||
f.seek(0)
|
||||
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
|
||||
|
||||
def render(self, form_data: dict) -> Tuple[str, str, bytes]:
|
||||
if form_data.get('_format') == 'xlsx':
|
||||
return self._render_xlsx(form_data)
|
||||
elif ':' in form_data.get('_format'):
|
||||
sheet, f = form_data.get('_format').split(':')
|
||||
if f == 'default':
|
||||
return self._render_sheet_csv(form_data, sheet, quoting=csv.QUOTE_NONNUMERIC, delimiter=',')
|
||||
elif f == 'excel':
|
||||
return self._render_sheet_csv(form_data, sheet, dialect='excel')
|
||||
elif f == 'semicolon':
|
||||
return self._render_sheet_csv(form_data, sheet, dialect='excel', delimiter=';')
|
||||
|
||||
@@ -27,7 +27,7 @@ class InvoiceExporter(BaseExporter):
|
||||
qs = qs.annotate(
|
||||
has_payment_with_provider=Exists(
|
||||
OrderPayment.objects.filter(
|
||||
Q(order=OuterRef('pk')) & Q(provider=form_data.get('payment_provider'))
|
||||
Q(order=OuterRef('order_id')) & Q(provider=form_data.get('payment_provider'))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ from ..signals import register_data_exporters
|
||||
|
||||
class JSONExporter(BaseExporter):
|
||||
identifier = 'json'
|
||||
verbose_name = 'JSON'
|
||||
verbose_name = 'Order data (JSON)'
|
||||
|
||||
def render(self, form_data):
|
||||
jo = {
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
import io
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
|
||||
import pytz
|
||||
from defusedcsv import csv
|
||||
from django import forms
|
||||
from django.db.models import DateTimeField, Max, OuterRef, Subquery, Sum
|
||||
from django.db.models import DateTimeField, F, Max, OuterRef, Subquery, Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import localize
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
|
||||
from pretix.base.models import InvoiceAddress, Order, OrderPosition
|
||||
from pretix.base.models import (
|
||||
InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
||||
)
|
||||
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..exporter import ListExporter, MultiSheetListExporter
|
||||
from ..signals import register_data_exporters
|
||||
|
||||
|
||||
class OrderListExporter(BaseExporter):
|
||||
identifier = 'orderlistcsv'
|
||||
verbose_name = ugettext_lazy('List of orders (CSV)')
|
||||
class OrderListExporter(MultiSheetListExporter):
|
||||
identifier = 'orderlist'
|
||||
verbose_name = ugettext_lazy('Order data')
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
def sheets(self):
|
||||
return (
|
||||
('orders', _('Orders')),
|
||||
('positions', _('Order positions')),
|
||||
('fees', _('Order fees')),
|
||||
)
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('paid_only',
|
||||
@@ -50,10 +59,16 @@ class OrderListExporter(BaseExporter):
|
||||
tax_rates = sorted(tax_rates)
|
||||
return tax_rates
|
||||
|
||||
def render(self, form_data: dict):
|
||||
output = io.StringIO()
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
if sheet == 'orders':
|
||||
return self.iterate_orders(form_data)
|
||||
elif sheet == 'positions':
|
||||
return self.iterate_positions(form_data)
|
||||
elif sheet == 'fees':
|
||||
return self.iterate_fees(form_data)
|
||||
|
||||
def iterate_orders(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
|
||||
|
||||
p_date = OrderPayment.objects.filter(
|
||||
order=OuterRef('pk'),
|
||||
@@ -74,7 +89,14 @@ class OrderListExporter(BaseExporter):
|
||||
|
||||
headers = [
|
||||
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
|
||||
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
_('Company'), _('Name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(label)
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
_('Date of last payment'), _('Fees'), _('Order locale')
|
||||
]
|
||||
|
||||
@@ -87,7 +109,7 @@ class OrderListExporter(BaseExporter):
|
||||
|
||||
headers.append(_('Invoice numbers'))
|
||||
|
||||
writer.writerow(headers)
|
||||
yield headers
|
||||
|
||||
full_fee_sum_cache = {
|
||||
o['order__id']: o['grosssum'] for o in
|
||||
@@ -118,6 +140,13 @@ class OrderListExporter(BaseExporter):
|
||||
row += [
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if 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,
|
||||
@@ -126,7 +155,7 @@ class OrderListExporter(BaseExporter):
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += ['', '', '', '', '', '', '']
|
||||
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
|
||||
row += [
|
||||
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
||||
@@ -147,17 +176,193 @@ class OrderListExporter(BaseExporter):
|
||||
]
|
||||
|
||||
row.append(', '.join([i.number for i in order.invoices.all()]))
|
||||
writer.writerow(row)
|
||||
yield row
|
||||
|
||||
return '{}_orders.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
|
||||
def iterate_fees(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
qs = OrderFee.objects.filter(
|
||||
order__event=self.event,
|
||||
).select_related('order', 'order__invoice_address', 'tax_rule')
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||
|
||||
headers = [
|
||||
_('Order code'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
_('Order date'),
|
||||
_('Fee type'),
|
||||
_('Description'),
|
||||
_('Price'),
|
||||
_('Tax rate'),
|
||||
_('Tax rule'),
|
||||
_('Tax value'),
|
||||
_('Company'),
|
||||
_('Invoice address name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if 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'), _('VAT ID'),
|
||||
]
|
||||
|
||||
yield headers
|
||||
|
||||
for op in qs.order_by('order__datetime'):
|
||||
order = op.order
|
||||
row = [
|
||||
order.code,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
op.get_fee_type_display(),
|
||||
op.description,
|
||||
op.value,
|
||||
op.tax_rate,
|
||||
str(op.tax_rule) if op.tax_rule else '',
|
||||
op.tax_value,
|
||||
]
|
||||
try:
|
||||
row += [
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if 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.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
yield row
|
||||
|
||||
def iterate_positions(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.event,
|
||||
).select_related(
|
||||
'order', 'order__invoice_address', 'item', 'variation',
|
||||
'voucher', 'tax_rule'
|
||||
).prefetch_related(
|
||||
'answers', 'answers__question'
|
||||
)
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||
|
||||
headers = [
|
||||
_('Order code'),
|
||||
_('Position ID'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
_('Order date'),
|
||||
_('Product'),
|
||||
_('Variation'),
|
||||
_('Price'),
|
||||
_('Tax rate'),
|
||||
_('Tax rule'),
|
||||
_('Tax value'),
|
||||
_('Attendee name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Attendee name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Attendee email'),
|
||||
_('Voucher'),
|
||||
_('Pseudonymization ID'),
|
||||
]
|
||||
questions = list(self.event.questions.all())
|
||||
for q in questions:
|
||||
headers.append(str(q.question))
|
||||
headers += [
|
||||
_('Company'),
|
||||
_('Invoice address name'),
|
||||
]
|
||||
if 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'), _('VAT ID'),
|
||||
]
|
||||
|
||||
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'),
|
||||
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.voucher.code if op.voucher else '',
|
||||
op.pseudonymization_id,
|
||||
]
|
||||
acache = {}
|
||||
for a in op.answers.all():
|
||||
acache[a.question_id] = str(a)
|
||||
for q in questions:
|
||||
row.append(acache.get(q.pk, ''))
|
||||
try:
|
||||
row += [
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if 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.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_orders'.format(self.event.slug)
|
||||
|
||||
|
||||
class PaymentListExporter(BaseExporter):
|
||||
identifier = 'paymentlistcsv'
|
||||
verbose_name = ugettext_lazy('List of payments and refunds (CSV)')
|
||||
class PaymentListExporter(ListExporter):
|
||||
identifier = 'paymentlist'
|
||||
verbose_name = ugettext_lazy('Order payments and refunds')
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('successful_only',
|
||||
@@ -169,10 +374,8 @@ class PaymentListExporter(BaseExporter):
|
||||
]
|
||||
)
|
||||
|
||||
def render(self, form_data: dict):
|
||||
output = io.StringIO()
|
||||
def iterate_list(self, form_data):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
|
||||
|
||||
provider_names = {
|
||||
k: v.verbose_name
|
||||
@@ -198,9 +401,9 @@ class PaymentListExporter(BaseExporter):
|
||||
|
||||
headers = [
|
||||
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||
_('Amount'), _('Payment method')
|
||||
_('Status code'), _('Amount'), _('Payment method')
|
||||
]
|
||||
writer.writerow(headers)
|
||||
yield headers
|
||||
|
||||
for obj in objs:
|
||||
if isinstance(obj, OrderPayment) and obj.payment_date:
|
||||
@@ -215,27 +418,26 @@ class PaymentListExporter(BaseExporter):
|
||||
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
d2,
|
||||
obj.get_state_display(),
|
||||
obj.state,
|
||||
localize(obj.amount * (-1 if isinstance(obj, OrderRefund) else 1)),
|
||||
provider_names.get(obj.provider, obj.provider)
|
||||
]
|
||||
writer.writerow(row)
|
||||
yield row
|
||||
|
||||
return '{}_payments.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
|
||||
def get_filename(self):
|
||||
return '{}_payments'.format(self.event.slug)
|
||||
|
||||
|
||||
class QuotaListExporter(BaseExporter):
|
||||
identifier = 'quotalistcsv'
|
||||
verbose_name = ugettext_lazy('Quota availabilities (CSV)')
|
||||
|
||||
def render(self, form_data: dict):
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
|
||||
class QuotaListExporter(ListExporter):
|
||||
identifier = 'quotalist'
|
||||
verbose_name = ugettext_lazy('Quota availabilities')
|
||||
|
||||
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')
|
||||
]
|
||||
writer.writerow(headers)
|
||||
yield headers
|
||||
|
||||
for quota in self.event.quotas.all():
|
||||
avail = quota.availability()
|
||||
@@ -249,9 +451,180 @@ class QuotaListExporter(BaseExporter):
|
||||
quota.count_waiting_list_pending(),
|
||||
_('Infinite') if avail[1] is None else avail[1]
|
||||
]
|
||||
writer.writerow(row)
|
||||
yield row
|
||||
|
||||
return '{}_quotas.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
|
||||
def get_filename(self):
|
||||
return '{}_quotas'.format(self.event.slug)
|
||||
|
||||
|
||||
class InvoiceDataExporter(MultiSheetListExporter):
|
||||
identifier = 'invoicedata'
|
||||
verbose_name = ugettext_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:') + ' ' + _('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)'),
|
||||
]
|
||||
qs = self.event.invoices.order_by('full_invoice_no').select_related(
|
||||
'order', 'refers'
|
||||
).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:
|
||||
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_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')),
|
||||
]
|
||||
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:') + ' ' + _('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_vat_id,
|
||||
i.invoice_to_beneficiary,
|
||||
i.internal_reference,
|
||||
]
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_invoices'.format(self.event.slug)
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
|
||||
@@ -267,3 +640,8 @@ def register_paymentlist_exporter(sender, **kwargs):
|
||||
@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
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
|
||||
import i18nfield.forms
|
||||
from django import forms
|
||||
from django.forms.models import ModelFormMetaclass
|
||||
from django.utils import six
|
||||
from django.utils.crypto import get_random_string
|
||||
from formtools.wizard.views import SessionWizardView
|
||||
from hierarkey.forms import HierarkeyForm
|
||||
|
||||
from pretix.base.models import Event
|
||||
@@ -71,3 +73,29 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
fname = '%s/%s.%s.%s' % (self.obj.slug, name, nonce, name.split('.')[-1])
|
||||
# TODO: make sure pub is always correct
|
||||
return 'pub/' + fname
|
||||
|
||||
|
||||
class PrefixForm(forms.Form):
|
||||
prefix = forms.CharField(widget=forms.HiddenInput)
|
||||
|
||||
|
||||
class SafeSessionWizardView(SessionWizardView):
|
||||
def get_prefix(self, request, *args, **kwargs):
|
||||
if hasattr(request, '_session_wizard_prefix'):
|
||||
return request._session_wizard_prefix
|
||||
prefix_form = PrefixForm(self.request.POST, prefix=super().get_prefix(request, *args, **kwargs))
|
||||
if not prefix_form.is_valid():
|
||||
request._session_wizard_prefix = get_random_string(length=24)
|
||||
else:
|
||||
request._session_wizard_prefix = prefix_form.cleaned_data['prefix']
|
||||
return request._session_wizard_prefix
|
||||
|
||||
def get_context_data(self, form, **kwargs):
|
||||
context = super().get_context_data(form=form, **kwargs)
|
||||
context['wizard']['prefix_form'] = PrefixForm(
|
||||
prefix=super().get_prefix(self.request),
|
||||
initial={
|
||||
'prefix': self.get_prefix(self.request)
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
@@ -120,7 +120,7 @@ class RegistrationForm(forms.Form):
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
if User.objects.filter(email=email).exists():
|
||||
if User.objects.filter(email__iexact=email).exists():
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_email'],
|
||||
code='duplicate_email'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -8,6 +9,7 @@ import vat_moss.id
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms.widgets import (
|
||||
@@ -16,6 +18,8 @@ from pretix.base.forms.widgets import (
|
||||
)
|
||||
from pretix.base.models import InvoiceAddress, Question
|
||||
from pretix.base.models.tax import EU_COUNTRIES
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.control.forms import SplitDateTimeField
|
||||
from pretix.helpers.i18n import get_format_without_seconds
|
||||
from pretix.presale.signals import question_form_fields
|
||||
@@ -23,6 +27,103 @@ from pretix.presale.signals import question_form_fields
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NamePartsWidget(forms.MultiWidget):
|
||||
widget = forms.TextInput
|
||||
|
||||
def __init__(self, scheme: dict, field: forms.Field, attrs=None):
|
||||
widgets = []
|
||||
self.scheme = scheme
|
||||
self.field = field
|
||||
for fname, label, size in self.scheme['fields']:
|
||||
a = copy.copy(attrs) or {}
|
||||
a['data-fname'] = fname
|
||||
widgets.append(self.widget(attrs=a))
|
||||
super().__init__(widgets, attrs)
|
||||
|
||||
def decompress(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
data = []
|
||||
for i, field in enumerate(self.scheme['fields']):
|
||||
fname, label, size = field
|
||||
data.append(value.get(fname, ""))
|
||||
if '_legacy' in value and not data[-1]:
|
||||
data[-1] = value.get('_legacy', '')
|
||||
return data
|
||||
|
||||
def render(self, name: str, value, attrs=None, renderer=None) -> str:
|
||||
if not isinstance(value, list):
|
||||
value = self.decompress(value)
|
||||
output = []
|
||||
final_attrs = self.build_attrs(attrs or dict())
|
||||
if 'required' in final_attrs:
|
||||
del final_attrs['required']
|
||||
id_ = final_attrs.get('id', None)
|
||||
for i, widget in enumerate(self.widgets):
|
||||
try:
|
||||
widget_value = value[i]
|
||||
except (IndexError, TypeError):
|
||||
widget_value = None
|
||||
if id_:
|
||||
final_attrs = dict(
|
||||
final_attrs,
|
||||
id='%s_%s' % (id_, i),
|
||||
title=self.scheme['fields'][i][1],
|
||||
placeholder=self.scheme['fields'][i][1],
|
||||
)
|
||||
final_attrs['data-size'] = self.scheme['fields'][i][2]
|
||||
output.append(widget.render(name + '_%s' % i, widget_value, final_attrs, renderer=renderer))
|
||||
return mark_safe(self.format_output(output))
|
||||
|
||||
def format_output(self, rendered_widgets) -> str:
|
||||
return '<div class="nameparts-form-group">%s</div>' % ''.join(rendered_widgets)
|
||||
|
||||
|
||||
class NamePartsFormField(forms.MultiValueField):
|
||||
widget = NamePartsWidget
|
||||
|
||||
def compress(self, data_list) -> dict:
|
||||
data = {}
|
||||
data['_scheme'] = self.scheme_name
|
||||
for i, value in enumerate(data_list):
|
||||
data[self.scheme['fields'][i][0]] = value or ''
|
||||
return data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
fields = []
|
||||
defaults = {
|
||||
'widget': self.widget,
|
||||
'max_length': kwargs.pop('max_length', None),
|
||||
}
|
||||
self.scheme_name = kwargs.pop('scheme')
|
||||
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
|
||||
self.one_required = kwargs.get('required', True)
|
||||
require_all_fields = kwargs.pop('require_all_fields', False)
|
||||
kwargs['required'] = False
|
||||
kwargs['widget'] = (kwargs.get('widget') or self.widget)(
|
||||
scheme=self.scheme, field=self, **kwargs.pop('widget_kwargs', {})
|
||||
)
|
||||
defaults.update(**kwargs)
|
||||
for fname, label, size in self.scheme['fields']:
|
||||
defaults['label'] = label
|
||||
field = forms.CharField(**defaults)
|
||||
field.part_name = fname
|
||||
fields.append(field)
|
||||
super().__init__(
|
||||
fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
self.require_all_fields = require_all_fields
|
||||
self.required = self.one_required
|
||||
|
||||
def clean(self, value) -> dict:
|
||||
value = super().clean(value)
|
||||
if self.one_required and (not value or not any(v for v in value)):
|
||||
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')
|
||||
return value
|
||||
|
||||
|
||||
class BaseQuestionsForm(forms.Form):
|
||||
"""
|
||||
This form class is responsible for asking order-related questions. This includes
|
||||
@@ -47,10 +148,12 @@ class BaseQuestionsForm(forms.Form):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if item.admission and event.settings.attendee_names_asked:
|
||||
self.fields['attendee_name'] = forms.CharField(
|
||||
max_length=255, required=event.settings.attendee_names_required,
|
||||
self.fields['attendee_name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.attendee_names_required,
|
||||
scheme=event.settings.name_scheme,
|
||||
label=_('Attendee name'),
|
||||
initial=(cartpos.attendee_name if cartpos else orderpos.attendee_name),
|
||||
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(
|
||||
@@ -67,6 +170,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
else:
|
||||
initial = None
|
||||
tz = pytz.timezone(event.settings.timezone)
|
||||
help_text = rich_text(q.help_text)
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
if q.required:
|
||||
# For some reason, django-bootstrap3 does not set the required attribute
|
||||
@@ -82,7 +186,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
|
||||
field = forms.BooleanField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
initial=initialbool, widget=widget,
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
@@ -95,13 +199,13 @@ class BaseQuestionsForm(forms.Form):
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
@@ -109,7 +213,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
empty_label='',
|
||||
initial=initial.options.first() if initial else None,
|
||||
@@ -118,35 +222,35 @@ class BaseQuestionsForm(forms.Form):
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=q.options,
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
initial=initial.options.all() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_FILE:
|
||||
field = forms.FileField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
field = forms.DateField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
|
||||
widget=DatePickerWidget(),
|
||||
)
|
||||
elif q.type == Question.TYPE_TIME:
|
||||
field = forms.TimeField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).time() if initial and initial.answer else None,
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
field = SplitDateTimeField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
|
||||
widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
@@ -170,13 +274,13 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||
'internal_reference')
|
||||
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||
'internal_reference', 'beneficiary')
|
||||
widgets = {
|
||||
'is_business': BusinessBooleanRadio,
|
||||
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
|
||||
'beneficiary': forms.Textarea(attrs={'rows': 3}),
|
||||
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'name': forms.TextInput(attrs={}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'internal_reference': forms.TextInput,
|
||||
}
|
||||
@@ -188,19 +292,18 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.event = event = kwargs.pop('event')
|
||||
self.request = kwargs.pop('request', None)
|
||||
self.validate_vat_id = kwargs.pop('validate_vat_id')
|
||||
self.all_optional = kwargs.pop('all_optional', False)
|
||||
super().__init__(*args, **kwargs)
|
||||
if not event.settings.invoice_address_vatid:
|
||||
del self.fields['vat_id']
|
||||
if not event.settings.invoice_address_required:
|
||||
|
||||
if not event.settings.invoice_address_required or self.all_optional:
|
||||
for k, f in self.fields.items():
|
||||
f.required = False
|
||||
f.widget.is_required = False
|
||||
if 'required' in f.widget.attrs:
|
||||
del f.widget.attrs['required']
|
||||
|
||||
if event.settings.invoice_name_required:
|
||||
self.fields['name'].required = True
|
||||
elif event.settings.invoice_address_company_required:
|
||||
elif event.settings.invoice_address_company_required and not self.all_optional:
|
||||
self.initial['is_business'] = True
|
||||
|
||||
self.fields['is_business'].widget = BusinessBooleanRadio(require_business=True)
|
||||
@@ -210,18 +313,37 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
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']
|
||||
else:
|
||||
|
||||
self.fields['name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.invoice_name_required and not self.all_optional,
|
||||
scheme=event.settings.name_scheme,
|
||||
label=_('Name'),
|
||||
initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
|
||||
)
|
||||
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
|
||||
self.fields['name_parts'].widget.attrs['data-required-if'] = '#id_is_business_0'
|
||||
self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1'
|
||||
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
|
||||
self.fields['name'].widget.attrs['data-required-if'] = '#id_is_business_0'
|
||||
|
||||
if not event.settings.invoice_address_beneficiary:
|
||||
del self.fields['beneficiary']
|
||||
|
||||
def clean(self):
|
||||
data = self.cleaned_data
|
||||
if not data.get('name') and not data.get('company') and self.event.settings.invoice_address_required:
|
||||
raise ValidationError(_('You need to provide either a company name or your name.'))
|
||||
if not data.get('is_business'):
|
||||
data['company'] = ''
|
||||
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.'))
|
||||
if not data.get('is_business') and not data.get('name_parts'):
|
||||
raise ValidationError(_('You need to provide your name.'))
|
||||
|
||||
if 'vat_id' in self.changed_data or not data.get('vat_id'):
|
||||
self.instance.vat_id_validated = False
|
||||
|
||||
self.instance.name_parts = data.get('name_parts')
|
||||
|
||||
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'):
|
||||
@@ -233,7 +355,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
country_code, normalized_id, company_name = result
|
||||
self.instance.vat_id_validated = True
|
||||
self.instance.vat_id = normalized_id
|
||||
except vat_moss.errors.InvalidError:
|
||||
except (vat_moss.errors.InvalidError, ValueError):
|
||||
raise ValidationError(_('This VAT ID is not valid. Please re-check your input.'))
|
||||
except vat_moss.errors.WebServiceUnavailableError:
|
||||
logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
|
||||
|
||||
@@ -69,7 +69,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
if User.objects.filter(Q(email=email) & ~Q(pk=self.instance.pk)).exists():
|
||||
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_identifier'],
|
||||
code='duplicate_identifier',
|
||||
|
||||
@@ -90,6 +90,8 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
time_attrs = dict(attrs)
|
||||
date_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
time_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
date_attrs.setdefault('autocomplete', 'off')
|
||||
time_attrs.setdefault('autocomplete', 'off')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from typing import Tuple
|
||||
|
||||
import bleach
|
||||
import vat_moss.exchange_rates
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import pgettext
|
||||
from django.utils.translation import pgettext, ugettext
|
||||
from PIL.Image import BICUBIC
|
||||
from reportlab.lib import pagesizes
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
@@ -31,6 +32,31 @@ from pretix.base.templatetags.money import money_filter
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NumberedCanvas(Canvas):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.font_regular = kwargs.pop('font_regular')
|
||||
super().__init__(*args, **kwargs)
|
||||
self._saved_page_states = []
|
||||
|
||||
def showPage(self):
|
||||
self._saved_page_states.append(dict(self.__dict__))
|
||||
self._startPage()
|
||||
|
||||
def save(self):
|
||||
num_pages = len(self._saved_page_states)
|
||||
for state in self._saved_page_states:
|
||||
self.__dict__.update(state)
|
||||
self.draw_page_number(num_pages)
|
||||
Canvas.showPage(self)
|
||||
Canvas.save(self)
|
||||
|
||||
def draw_page_number(self, page_count):
|
||||
self.saveState()
|
||||
self.setFont(self.font_regular, 8)
|
||||
self.drawRightString(self._pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d of %d") % (self._pageNumber, page_count,))
|
||||
self.restoreState()
|
||||
|
||||
|
||||
class BaseInvoiceRenderer:
|
||||
"""
|
||||
This is the base class for all invoice renderers.
|
||||
@@ -79,6 +105,9 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
top_margin = 20 * mm
|
||||
bottom_margin = 15 * mm
|
||||
doc_template_class = BaseDocTemplate
|
||||
canvas_class = Canvas
|
||||
font_regular = 'OpenSans'
|
||||
font_bold = 'OpenSansBd'
|
||||
|
||||
def _init(self):
|
||||
"""
|
||||
@@ -92,10 +121,10 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
Get a stylesheet. By default, this contains the "Normal" and "Heading1" styles.
|
||||
"""
|
||||
stylesheet = StyleSheet1()
|
||||
stylesheet.add(ParagraphStyle(name='Normal', fontName='OpenSans', fontSize=10, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Heading1', fontName='OpenSansBd', fontSize=15, leading=15 * 1.2))
|
||||
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName='OpenSansBd', fontSize=8, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Fineprint', fontName='OpenSans', fontSize=8, leading=10))
|
||||
stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2))
|
||||
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10))
|
||||
return stylesheet
|
||||
|
||||
def _register_fonts(self):
|
||||
@@ -171,7 +200,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
)
|
||||
])
|
||||
story = self._get_story(doc)
|
||||
doc.build(story)
|
||||
doc.build(story, canvasmaker=self.canvas_class)
|
||||
return doc
|
||||
|
||||
def generate(self, invoice: Invoice):
|
||||
@@ -192,17 +221,27 @@ class ThumbnailingImageReader(ImageReader):
|
||||
size=(int(width * dpi / 72), int(height * dpi / 72)),
|
||||
resample=BICUBIC
|
||||
)
|
||||
self._data = None
|
||||
return width, height
|
||||
|
||||
def _jpeg_fh(self):
|
||||
# Bypass a reportlab-internal optimization that falls back to the original
|
||||
# file handle if the file is a JPEG, and therefore does not respect the
|
||||
# (smaller) size of the modified image.
|
||||
return None
|
||||
|
||||
|
||||
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
identifier = 'classic'
|
||||
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
|
||||
|
||||
def canvas_class(self, *args, **kwargs):
|
||||
kwargs['font_regular'] = self.font_regular
|
||||
return NumberedCanvas(*args, **kwargs)
|
||||
|
||||
def _on_other_page(self, canvas: Canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSans', 8)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,))
|
||||
canvas.setFont(self.font_regular, 8)
|
||||
|
||||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||
@@ -216,7 +255,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
|
||||
|
||||
def _draw_invoice_from(self, canvas):
|
||||
p = Paragraph(self.invoice.invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p = Paragraph(self.invoice.full_invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p.wrapOn(canvas, 70 * mm, 50 * mm)
|
||||
p_size = p.wrap(70 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
|
||||
@@ -226,72 +265,78 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
|
||||
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSans', 8)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,))
|
||||
canvas.setFont(self.font_regular, 8)
|
||||
|
||||
if self.invoice.order.testmode:
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSansBd', 30)
|
||||
canvas.setFillColorRGB(32, 0, 0)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, ugettext('TEST MODE'))
|
||||
canvas.restoreState()
|
||||
|
||||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
self._draw_invoice_from(canvas)
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
self._draw_invoice_to(canvas)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Order code').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.order.full_code)
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
if self.invoice.is_cancellation:
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation number').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.refers.number)
|
||||
else:
|
||||
textobject.textLine(pgettext('invoice', 'Invoice number').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
|
||||
if self.invoice.is_cancellation:
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(date_format(self.invoice.refers.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
else:
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
|
||||
@@ -323,7 +368,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
return txt
|
||||
|
||||
if not self.invoice.event.has_subevents:
|
||||
if self.invoice.event.settings.show_date_to:
|
||||
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(),
|
||||
@@ -342,7 +387,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Event').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
@@ -383,6 +428,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.invoice_to_beneficiary:
|
||||
story.append(Paragraph(
|
||||
pgettext('invoice', 'Beneficiary') + ':<br />' +
|
||||
bleach.clean(self.invoice.invoice_to_beneficiary, tags=[]).replace("\n", "<br />\n"),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.introductory_text:
|
||||
story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal']))
|
||||
story.append(Spacer(1, 10 * mm))
|
||||
@@ -393,8 +445,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
tstyledata = [
|
||||
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'),
|
||||
('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'),
|
||||
('FONTNAME', (0, 0), (-1, 0), self.font_bold),
|
||||
('FONTNAME', (0, -1), (-1, -1), self.font_bold),
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
]
|
||||
@@ -462,7 +514,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||
('FONTNAME', (0, 0), (-1, -1), 'OpenSans'),
|
||||
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
|
||||
]
|
||||
thead = [
|
||||
pgettext('invoice', 'Tax rate'),
|
||||
|
||||
@@ -129,13 +129,22 @@ def get_language_from_request(request: HttpRequest) -> str:
|
||||
if _supported is None:
|
||||
_supported = OrderedDict(settings.LANGUAGES)
|
||||
|
||||
return (
|
||||
get_language_from_user_settings(request)
|
||||
or get_language_from_session_or_cookie(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
or get_default_language()
|
||||
)
|
||||
if request.path.startswith(get_script_prefix() + 'control'):
|
||||
return (
|
||||
get_language_from_user_settings(request)
|
||||
or get_language_from_session_or_cookie(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
or get_default_language()
|
||||
)
|
||||
else:
|
||||
return (
|
||||
get_language_from_session_or_cookie(request)
|
||||
or get_language_from_user_settings(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
or get_default_language()
|
||||
)
|
||||
|
||||
|
||||
def _parse_csp(header):
|
||||
@@ -189,6 +198,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'connect-src': ["{dynamic}", "{media}", "https://checkout.stripe.com"],
|
||||
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"],
|
||||
'font-src': ["{static}"],
|
||||
'media-src': ["{static}", "data:"],
|
||||
# form-action is not only used to match on form actions, but also on URLs
|
||||
# form-actions redirect to. In the context of e.g. payment providers or
|
||||
# single-sign-on this can be nearly anything so we cannot really restrict
|
||||
|
||||
@@ -28,7 +28,8 @@ class Migration(migrations.Migration):
|
||||
('password', models.CharField(verbose_name='password', max_length=128)),
|
||||
('last_login', models.DateTimeField(verbose_name='last login', blank=True, null=True)),
|
||||
('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')),
|
||||
('email', models.EmailField(max_length=254, blank=True, unique=True, verbose_name='E-mail', null=True, db_index=True)),
|
||||
('email', models.EmailField(max_length=191, blank=True, unique=True, verbose_name='E-mail', null=True,
|
||||
db_index=True)),
|
||||
('givenname', models.CharField(verbose_name='Given name', max_length=255, blank=True, null=True)),
|
||||
('familyname', models.CharField(verbose_name='Family name', max_length=255, blank=True, null=True)),
|
||||
('is_active', models.BooleanField(verbose_name='Is active', default=True)),
|
||||
|
||||
79
src/pretix/base/migrations/0100_auto_20181023_2300.py
Normal file
79
src/pretix/base/migrations/0100_auto_20181023_2300.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 2.1 on 2018-10-23 23:00
|
||||
|
||||
import django_countries.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0099_auto_20180912_1035'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_city',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_country',
|
||||
field=django_countries.fields.CountryField(max_length=2, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_name',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_tax_id',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_vat_id',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_zipcode',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_city',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_company',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_country',
|
||||
field=django_countries.fields.CountryField(max_length=2, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_name',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_street',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_vat_id',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_zipcode',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0101_auto_20181025_2255.py
Normal file
18
src/pretix/base/migrations/0101_auto_20181025_2255.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.1 on 2018-10-25 22:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0100_auto_20181023_2300'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='reverse_charge',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
96
src/pretix/base/migrations/0102_auto_20181017_0024.py
Normal file
96
src/pretix/base/migrations/0102_auto_20181017_0024.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# Generated by Django 2.1 on 2018-10-17 00:24
|
||||
|
||||
import jsonfallback.fields
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import migrations
|
||||
from django_mysql.checks import mysql_connections
|
||||
from django_mysql.utils import connection_is_mariadb
|
||||
|
||||
|
||||
def set_attendee_name_parts(apps, schema_editor):
|
||||
OrderPosition = apps.get_model('pretixbase', 'OrderPosition') # noqa
|
||||
for op in OrderPosition.objects.exclude(attendee_name_cached=None).exclude(
|
||||
attendee_name_cached__isnull=True).iterator():
|
||||
op.attendee_name_parts = {'_legacy': op.attendee_name_cached}
|
||||
op.save(update_fields=['attendee_name_parts'])
|
||||
CartPosition = apps.get_model('pretixbase', 'CartPosition') # noqa
|
||||
for op in CartPosition.objects.exclude(attendee_name_cached=None).exclude(
|
||||
attendee_name_cached__isnull=True).iterator():
|
||||
op.attendee_name_parts = {'_legacy': op.attendee_name_cached}
|
||||
op.save(update_fields=['attendee_name_parts'])
|
||||
InvoiceAddress = apps.get_model('pretixbase', 'InvoiceAddress') # noqa
|
||||
for ia in InvoiceAddress.objects.exclude(name_cached=None).exclude(
|
||||
name_cached__isnull=True).iterator():
|
||||
ia.name_parts = {'_legacy': ia.name_cached}
|
||||
ia.save(update_fields=['name_parts'])
|
||||
|
||||
|
||||
def check_mysqlversion(apps, schema_editor):
|
||||
errors = []
|
||||
any_conn_works = False
|
||||
conns = list(mysql_connections())
|
||||
found = 'Unknown version'
|
||||
for alias, conn in conns:
|
||||
if connection_is_mariadb(conn) and hasattr(conn, 'mysql_version'):
|
||||
if conn.mysql_version >= (10, 2, 7):
|
||||
any_conn_works = True
|
||||
else:
|
||||
found = 'MariaDB ' + '.'.join(str(v) for v in conn.mysql_version)
|
||||
elif hasattr(conn, 'mysql_version'):
|
||||
if conn.mysql_version >= (5, 7):
|
||||
any_conn_works = True
|
||||
else:
|
||||
found = 'MySQL ' + '.'.join(str(v) for v in conn.mysql_version)
|
||||
|
||||
if conns and not any_conn_works:
|
||||
raise ImproperlyConfigured(
|
||||
'As of pretix 2.2, you need MySQL 5.7+ or MariaDB 10.2.7+ to run pretix. However, we detected a '
|
||||
'database connection to {}'.format(found)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0101_auto_20181025_2255'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
check_mysqlversion, migrations.RunPython.noop
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='cartposition',
|
||||
old_name='attendee_name',
|
||||
new_name='attendee_name_cached',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='orderposition',
|
||||
old_name='attendee_name',
|
||||
new_name='attendee_name_cached',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='invoiceaddress',
|
||||
old_name='name',
|
||||
new_name='name_cached',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='attendee_name_parts',
|
||||
field=jsonfallback.fields.FallbackJSONField(null=False, default=dict),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='attendee_name_parts',
|
||||
field=jsonfallback.fields.FallbackJSONField(null=False, default=dict),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='name_parts',
|
||||
field=jsonfallback.fields.FallbackJSONField(default=dict),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(set_attendee_name_parts, migrations.RunPython.noop)
|
||||
]
|
||||
29
src/pretix/base/migrations/0103_auto_20181121_1224.py
Normal file
29
src/pretix/base/migrations/0103_auto_20181121_1224.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 2.1.1 on 2018-11-21 12:24
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0102_auto_20181017_0024'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='sales_channels',
|
||||
field=pretix.base.models.fields.MultiStringField(default=['web'], verbose_name='Sales channels'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='sales_channel',
|
||||
field=models.CharField(default='web', max_length=190),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
62
src/pretix/base/migrations/0104_auto_20181114_1526.py
Normal file
62
src/pretix/base/migrations/0104_auto_20181114_1526.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 2.1.1 on 2018-11-14 15:26
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.manager
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def change_refunded_to_canceled(apps, schema_editor):
|
||||
Order = apps.get_model('pretixbase', 'Order')
|
||||
Order.objects.filter(status='r').update(status='c')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0103_auto_20181121_1224'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='orderposition',
|
||||
managers=[
|
||||
('all', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='canceled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderfee',
|
||||
name='fee_type',
|
||||
field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'), ('cancellation', 'Cancellation fee'), ('other', 'Other fees'), ('giftcard', 'Gift card')], max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='all_positions',
|
||||
to='pretixbase.Order', verbose_name='Order'),
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='orderfee',
|
||||
managers=[
|
||||
('all', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderfee',
|
||||
name='canceled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderfee',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='all_fees', to='pretixbase.Order', verbose_name='Order'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
change_refunded_to_canceled, migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
27
src/pretix/base/migrations/0105_auto_20190112_1512.py
Normal file
27
src/pretix/base/migrations/0105_auto_20190112_1512.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.1 on 2019-01-12 15:12
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0104_auto_20181114_1526'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='beneficiary',
|
||||
field=models.TextField(blank=True, verbose_name='Beneficiary'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_beneficiary',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Beneficiary'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 2.1.5 on 2019-02-04 13:02
|
||||
|
||||
import django.db.migrations.operations.special
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def enable_notifications_for_everyone(apps, schema_editor):
|
||||
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
|
||||
User = apps.get_model('pretixbase', 'User')
|
||||
create = []
|
||||
for u in User.objects.iterator():
|
||||
create.append(NotificationSetting(
|
||||
user=u,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
event=None,
|
||||
method='mail',
|
||||
enabled=True
|
||||
))
|
||||
if len(create) > 200:
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
replaces = [('pretixbase', '0105_auto_20190112_1512'), ('pretixbase', '0106_auto_20190118_1527'),
|
||||
('pretixbase', '0107_auto_20190129_1337')]
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0104_auto_20181114_1526'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='beneficiary',
|
||||
field=models.TextField(blank=True, verbose_name='Beneficiary'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_beneficiary',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Beneficiary'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=enable_notifications_for_everyone,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Date'),
|
||||
),
|
||||
]
|
||||
31
src/pretix/base/migrations/0106_auto_20190118_1527.py
Normal file
31
src/pretix/base/migrations/0106_auto_20190118_1527.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 2.1.5 on 2019-01-18 15:27
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def enable_notifications_for_everyone(apps, schema_editor):
|
||||
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
|
||||
User = apps.get_model('pretixbase', 'User')
|
||||
create = []
|
||||
for u in User.objects.iterator():
|
||||
create.append(NotificationSetting(
|
||||
user=u,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
event=None,
|
||||
method='mail',
|
||||
enabled=True
|
||||
))
|
||||
if len(create) > 200:
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0105_auto_20190112_1512'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(enable_notifications_for_everyone, migrations.RunPython.noop)
|
||||
]
|
||||
22
src/pretix/base/migrations/0107_auto_20190129_1337.py
Normal file
22
src/pretix/base/migrations/0107_auto_20190129_1337.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.1.5 on 2019-01-29 13:37
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0106_auto_20190118_1527'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Date'),
|
||||
),
|
||||
]
|
||||
22
src/pretix/base/migrations/0108_auto_20190201_1527.py
Normal file
22
src/pretix/base/migrations/0108_auto_20190201_1527.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.1.5 on 2019-02-01 15:27
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0107_auto_20190129_1337'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='generate_tickets',
|
||||
field=models.NullBooleanField(verbose_name='Allow ticket download'),
|
||||
),
|
||||
]
|
||||
27
src/pretix/base/migrations/0109_auto_20190208_1432.py
Normal file
27
src/pretix/base/migrations/0109_auto_20190208_1432.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.1 on 2019-02-08 14:32
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0108_auto_20190201_1527'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceline',
|
||||
name='event_date_from',
|
||||
field=models.DateTimeField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoiceline',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
|
||||
),
|
||||
]
|
||||
23
src/pretix/base/migrations/0110_auto_20190219_1245.py
Normal file
23
src/pretix/base/migrations/0110_auto_20190219_1245.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.1.5 on 2019-02-19 12:45
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0109_auto_20190208_1432'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='plugins',
|
||||
field=models.TextField(blank=True, default='', verbose_name='Plugins'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
27
src/pretix/base/migrations/0111_auto_20190219_0949.py
Normal file
27
src/pretix/base/migrations/0111_auto_20190219_0949.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.1.5 on 2019-02-19 09:49
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0110_auto_20190219_1245'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='testmode',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='testmode',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -75,7 +75,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
|
||||
verbose_name=_('E-mail'))
|
||||
verbose_name=_('E-mail'), max_length=190)
|
||||
fullname = models.CharField(max_length=255, blank=True, null=True,
|
||||
verbose_name=_('Full name'))
|
||||
is_active = models.BooleanField(default=True,
|
||||
@@ -114,7 +114,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email = self.email.lower()
|
||||
is_new = not self.pk
|
||||
super().save(*args, **kwargs)
|
||||
if is_new:
|
||||
self.notification_settings.create(
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
event=None,
|
||||
method='mail',
|
||||
enabled=True
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
@@ -3,6 +3,7 @@ import uuid
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models.constants import LOOKUP_SEP
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.utils.crypto import get_random_string
|
||||
@@ -52,6 +53,7 @@ class LoggingMixin:
|
||||
from .organizer import TeamAPIToken
|
||||
from ..notifications import get_all_notification_types
|
||||
from ..services.notifications import notify
|
||||
from pretix.api.webhooks import get_all_webhook_events, notify_webhooks
|
||||
|
||||
event = None
|
||||
if isinstance(self, Event):
|
||||
@@ -74,13 +76,35 @@ class LoggingMixin:
|
||||
kwargs['api_token'] = api_token
|
||||
|
||||
logentry = LogEntry(content_object=self, user=user, action_type=action, event=event, **kwargs)
|
||||
if data:
|
||||
if isinstance(data, dict):
|
||||
sensitivekeys = ['password', 'secret', 'api_key']
|
||||
|
||||
for sensitivekey in sensitivekeys:
|
||||
for k, v in data.items():
|
||||
if (sensitivekey in k) and v:
|
||||
data[k] = "********"
|
||||
|
||||
logentry.data = json.dumps(data, cls=CustomJSONEncoder)
|
||||
elif data:
|
||||
raise TypeError("You should only supply dictionaries as log data.")
|
||||
if save:
|
||||
logentry.save()
|
||||
|
||||
if action in get_all_notification_types():
|
||||
no_types = get_all_notification_types()
|
||||
wh_types = get_all_webhook_events()
|
||||
|
||||
no_type = None
|
||||
wh_type = None
|
||||
typepath = logentry.action_type
|
||||
while (not no_type or not wh_types) and '.' in typepath:
|
||||
wh_type = wh_type or wh_types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
|
||||
no_type = no_type or no_types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
|
||||
typepath = typepath.rsplit('.', 1)[0]
|
||||
|
||||
if no_type:
|
||||
notify.apply_async(args=(logentry.pk,))
|
||||
if wh_type:
|
||||
notify_webhooks.apply_async(args=(logentry.pk,))
|
||||
return logentry
|
||||
|
||||
|
||||
@@ -100,3 +124,49 @@ class LoggedModel(models.Model, LoggingMixin):
|
||||
return LogEntry.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
|
||||
).select_related('user', 'event', 'oauth_application', 'api_token', 'device')
|
||||
|
||||
|
||||
class LockModel:
|
||||
def refresh_for_update(self, fields=None, using=None, **kwargs):
|
||||
"""
|
||||
Like refresh_from_db(), but with select_for_update().
|
||||
See also https://code.djangoproject.com/ticket/28344
|
||||
"""
|
||||
if fields is not None:
|
||||
if not fields:
|
||||
return
|
||||
if any(LOOKUP_SEP in f for f in fields):
|
||||
raise ValueError(
|
||||
'Found "%s" in fields argument. Relations and transforms '
|
||||
'are not allowed in fields.' % LOOKUP_SEP)
|
||||
|
||||
hints = {'instance': self}
|
||||
db_instance_qs = self.__class__._base_manager.db_manager(using, hints=hints).filter(pk=self.pk).select_for_update(**kwargs)
|
||||
|
||||
# Use provided fields, if not set then reload all non-deferred fields.
|
||||
deferred_fields = self.get_deferred_fields()
|
||||
if fields is not None:
|
||||
fields = list(fields)
|
||||
db_instance_qs = db_instance_qs.only(*fields)
|
||||
elif deferred_fields:
|
||||
fields = [f.attname for f in self._meta.concrete_fields
|
||||
if f.attname not in deferred_fields]
|
||||
db_instance_qs = db_instance_qs.only(*fields)
|
||||
|
||||
db_instance = db_instance_qs.get()
|
||||
non_loaded_fields = db_instance.get_deferred_fields()
|
||||
for field in self._meta.concrete_fields:
|
||||
if field.attname in non_loaded_fields:
|
||||
# This field wasn't refreshed - skip ahead.
|
||||
continue
|
||||
setattr(self, field.attname, getattr(db_instance, field.attname))
|
||||
# Clear cached foreign keys.
|
||||
if field.is_relation and field.is_cached(self):
|
||||
field.delete_cached_value(self)
|
||||
|
||||
# Clear cached relations.
|
||||
for field in self._meta.related_objects:
|
||||
if field.is_cached(self):
|
||||
field.delete_cached_value(self)
|
||||
|
||||
self._state.db = db_instance._state.db
|
||||
|
||||
@@ -20,6 +20,9 @@ class CheckinList(LoggedModel):
|
||||
'order have not been paid. This only works with pretixdesk '
|
||||
'0.3.0 or newer or pretixdroid 1.9 or newer.'))
|
||||
|
||||
class Meta:
|
||||
ordering = ('subevent__date_from', 'name')
|
||||
|
||||
@staticmethod
|
||||
def annotate_with_numbers(qs, event):
|
||||
"""
|
||||
|
||||
@@ -11,7 +11,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, Q
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
|
||||
from django.template.defaultfilters import date as _date
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
@@ -22,6 +22,7 @@ from i18nfield.fields import I18nCharField, I18nTextField
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.validators import EventSlugBlacklistValidator
|
||||
from pretix.helpers.database import GroupConcat
|
||||
from pretix.helpers.daterange import daterange
|
||||
from pretix.helpers.json import safe_string
|
||||
|
||||
@@ -159,6 +160,79 @@ class EventMixin:
|
||||
|
||||
return safe_string(json.dumps(eventdict))
|
||||
|
||||
@classmethod
|
||||
def annotated(cls, qs, channel='web'):
|
||||
from pretix.base.models import Item, ItemVariation, Quota
|
||||
|
||||
sq_active_item = Item.objects.filter_available(channel=channel).filter(
|
||||
Q(variations__isnull=True)
|
||||
& Q(quotas__pk=OuterRef('pk'))
|
||||
).order_by().values_list('quotas__pk').annotate(
|
||||
items=GroupConcat('pk', delimiter=',')
|
||||
).values('items')
|
||||
sq_active_variation = ItemVariation.objects.filter(
|
||||
Q(active=True)
|
||||
& Q(item__active=True)
|
||||
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
|
||||
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
|
||||
& Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False))
|
||||
& Q(item__sales_channels__contains=channel)
|
||||
& Q(item__hide_without_voucher=False) # TODO: does this make sense?
|
||||
& Q(quotas__pk=OuterRef('pk'))
|
||||
).order_by().values_list('quotas__pk').annotate(
|
||||
items=GroupConcat('pk', delimiter=',')
|
||||
).values('items')
|
||||
return qs.prefetch_related(
|
||||
Prefetch(
|
||||
'quotas',
|
||||
to_attr='active_quotas',
|
||||
queryset=Quota.objects.annotate(
|
||||
active_items=Subquery(sq_active_item, output_field=models.TextField()),
|
||||
active_variations=Subquery(sq_active_variation, output_field=models.TextField()),
|
||||
).exclude(
|
||||
Q(active_items="") & Q(active_variations="")
|
||||
).select_related('event', 'subevent')
|
||||
)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def best_availability_state(self):
|
||||
from .items import Quota
|
||||
|
||||
if not hasattr(self, 'active_quotas'):
|
||||
raise TypeError("Call this only if you fetched the subevents via Event/SubEvent.annotated()")
|
||||
items_available = set()
|
||||
vars_available = set()
|
||||
items_reserved = set()
|
||||
vars_reserved = set()
|
||||
items_gone = set()
|
||||
vars_gone = set()
|
||||
for q in self.active_quotas:
|
||||
res = q.availability(allow_cache=True)
|
||||
|
||||
if res[0] == Quota.AVAILABILITY_OK:
|
||||
if q.active_items:
|
||||
items_available.update(q.active_items.split(","))
|
||||
if q.active_variations:
|
||||
vars_available.update(q.active_variations.split(","))
|
||||
elif res[0] == Quota.AVAILABILITY_RESERVED:
|
||||
if q.active_items:
|
||||
items_reserved.update(q.active_items.split(","))
|
||||
if q.active_variations:
|
||||
vars_available.update(q.active_variations.split(","))
|
||||
elif res[0] < Quota.AVAILABILITY_RESERVED:
|
||||
if q.active_items:
|
||||
items_gone.update(q.active_items.split(","))
|
||||
if q.active_variations:
|
||||
vars_gone.update(q.active_variations.split(","))
|
||||
if not self.active_quotas:
|
||||
return None
|
||||
if items_available - items_reserved - items_gone or vars_available - vars_reserved - vars_gone:
|
||||
return Quota.AVAILABILITY_OK
|
||||
if items_reserved - items_gone or vars_reserved - vars_gone:
|
||||
return Quota.AVAILABILITY_RESERVED
|
||||
return Quota.AVAILABILITY_GONE
|
||||
|
||||
|
||||
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
|
||||
class Event(EventMixin, LoggedModel):
|
||||
@@ -168,6 +242,8 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
:param organizer: The organizer this event belongs to
|
||||
:type organizer: Organizer
|
||||
:param testmode: This event is in test mode
|
||||
:type testmode: bool
|
||||
:param name: This event's full title
|
||||
:type name: str
|
||||
:param slug: A short, alphanumeric, all-lowercase name for use in URLs. The slug has to
|
||||
@@ -197,6 +273,7 @@ class Event(EventMixin, LoggedModel):
|
||||
settings_namespace = 'event'
|
||||
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)
|
||||
name = I18nCharField(
|
||||
max_length=200,
|
||||
verbose_name=_("Event name"),
|
||||
@@ -227,10 +304,9 @@ class Event(EventMixin, LoggedModel):
|
||||
verbose_name=_("Event end time"))
|
||||
date_admission = models.DateTimeField(null=True, blank=True,
|
||||
verbose_name=_("Admission time"))
|
||||
is_public = models.BooleanField(default=False,
|
||||
verbose_name=_("Visible in public lists"),
|
||||
help_text=_("If selected, this event may show up on the ticket system's start page "
|
||||
"or an organization profile."))
|
||||
is_public = models.BooleanField(default=True,
|
||||
verbose_name=_("Show in lists"),
|
||||
help_text=_("If selected, this event will show up publicly on the list of events for your organizer account."))
|
||||
presale_end = models.DateTimeField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_("End of presale"),
|
||||
@@ -248,7 +324,7 @@ class Event(EventMixin, LoggedModel):
|
||||
verbose_name=_("Location"),
|
||||
)
|
||||
plugins = models.TextField(
|
||||
null=True, blank=True,
|
||||
null=False, blank=True,
|
||||
verbose_name=_("Plugins"),
|
||||
)
|
||||
comment = models.TextField(
|
||||
@@ -282,10 +358,11 @@ class Event(EventMixin, LoggedModel):
|
||||
if not really:
|
||||
raise TypeError("Pass really=True as a parameter.")
|
||||
|
||||
OrderPosition.objects.all().delete(order__event=self)
|
||||
OrderFee.objects.all().delete(order__event=self)
|
||||
OrderPayment.objects.all().delete(order__event=self)
|
||||
OrderRefund.objects.all().delete(order__event=self)
|
||||
OrderPosition.all.filter(order__event=self, addon_to__isnull=False).delete()
|
||||
OrderPosition.all.filter(order__event=self).delete()
|
||||
OrderFee.objects.filter(order__event=self).delete()
|
||||
OrderRefund.objects.filter(order__event=self).delete()
|
||||
OrderPayment.objects.filter(order__event=self).delete()
|
||||
self.orders.all().delete()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -301,7 +378,7 @@ class Event(EventMixin, LoggedModel):
|
||||
return []
|
||||
return self.plugins.split(",")
|
||||
|
||||
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
|
||||
def get_cache(self):
|
||||
"""
|
||||
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
|
||||
Django's built-in cache backends, but puts you into an isolated environment for
|
||||
@@ -469,6 +546,7 @@ class Event(EventMixin, LoggedModel):
|
||||
else:
|
||||
s.save()
|
||||
|
||||
self.settings.flush()
|
||||
event_copy_data.send(
|
||||
sender=self, other=other,
|
||||
tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map,
|
||||
@@ -573,8 +651,10 @@ class Event(EventMixin, LoggedModel):
|
||||
)
|
||||
).order_by('date_from', 'name')
|
||||
|
||||
@property
|
||||
def subevent_list_subevents(self):
|
||||
def subevents_annotated(self, channel):
|
||||
return SubEvent.annotated(self.subevents, channel)
|
||||
|
||||
def subevents_sorted(self, queryset):
|
||||
ordering = self.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
|
||||
orderfields = {
|
||||
'date_ascending': ('date_from', 'name'),
|
||||
@@ -582,7 +662,7 @@ class Event(EventMixin, LoggedModel):
|
||||
'name_ascending': ('name', 'date_from'),
|
||||
'name_descending': ('-name', 'date_from'),
|
||||
}[ordering]
|
||||
subevs = self.subevents.filter(
|
||||
subevs = queryset.filter(
|
||||
Q(active=True) & (
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(date_to__gte=now())
|
||||
@@ -675,6 +755,7 @@ class Event(EventMixin, LoggedModel):
|
||||
return not self.orders.exists() and not self.invoices.exists()
|
||||
|
||||
def delete_sub_objects(self):
|
||||
self.cartposition_set.all().delete()
|
||||
self.items.all().delete()
|
||||
self.subevents.all().delete()
|
||||
|
||||
@@ -683,7 +764,7 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
plugins_active = self.get_plugins()
|
||||
plugins_available = {
|
||||
p.module: p for p in get_all_plugins()
|
||||
p.module: p for p in get_all_plugins(self)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
|
||||
|
||||
94
src/pretix/base/models/fields.py
Normal file
94
src/pretix/base/models/fields.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from django.core import exceptions
|
||||
from django.db.models import TextField, lookups as builtin_lookups
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
DELIMITER = "\x1F"
|
||||
|
||||
|
||||
class MultiStringField(TextField):
|
||||
default_error_messages = {
|
||||
'delimiter_found': _('No value can contain the delimiter character.')
|
||||
}
|
||||
|
||||
def __init__(self, verbose_name=None, name=None, **kwargs):
|
||||
super().__init__(verbose_name, name, **kwargs)
|
||||
|
||||
def deconstruct(self):
|
||||
name, path, args, kwargs = super().deconstruct()
|
||||
return name, path, args, kwargs
|
||||
|
||||
def to_python(self, value):
|
||||
if isinstance(value, (list, tuple)):
|
||||
return value
|
||||
elif value:
|
||||
return [v for v in value.split(DELIMITER) if v]
|
||||
else:
|
||||
return []
|
||||
|
||||
def get_prep_value(self, value):
|
||||
if isinstance(value, (list, tuple)):
|
||||
return DELIMITER + DELIMITER.join(value) + DELIMITER
|
||||
elif value is None:
|
||||
return ""
|
||||
raise TypeError("Invalid data type passed.")
|
||||
|
||||
def get_prep_lookup(self, lookup_type, value): # NOQA
|
||||
raise TypeError('Lookups on multi strings are currently not supported.')
|
||||
|
||||
def from_db_value(self, value, expression, connection, context):
|
||||
if value:
|
||||
return [v for v in value.split(DELIMITER) if v]
|
||||
else:
|
||||
return []
|
||||
|
||||
def validate(self, value, model_instance):
|
||||
super().validate(value, model_instance)
|
||||
for l in value:
|
||||
if DELIMITER in l:
|
||||
raise exceptions.ValidationError(
|
||||
self.error_messages['delimiter_found'],
|
||||
code='delimiter_found',
|
||||
)
|
||||
|
||||
def get_lookup(self, lookup_name):
|
||||
if lookup_name == 'contains':
|
||||
return MultiStringContains
|
||||
elif lookup_name == 'icontains':
|
||||
return MultiStringIContains
|
||||
raise NotImplementedError(
|
||||
"Lookup '{}' doesn't work with MultiStringField".format(lookup_name),
|
||||
)
|
||||
|
||||
|
||||
class MultiStringContains(builtin_lookups.Contains):
|
||||
def process_rhs(self, qn, connection):
|
||||
sql, params = super().process_rhs(qn, connection)
|
||||
params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%"
|
||||
return sql, params
|
||||
|
||||
|
||||
class MultiStringIContains(builtin_lookups.IContains):
|
||||
def process_rhs(self, qn, connection):
|
||||
sql, params = super().process_rhs(qn, connection)
|
||||
params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%"
|
||||
return sql, params
|
||||
|
||||
|
||||
class MultiStringSerializer(serializers.Field):
|
||||
def __init__(self, **kwargs):
|
||||
self.allow_blank = kwargs.pop('allow_blank', False)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def to_representation(self, value):
|
||||
return value
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
else:
|
||||
raise ValidationError('Invalid data type.')
|
||||
|
||||
|
||||
serializers.ModelSerializer.serializer_field_mapping[MultiStringField] = MultiStringSerializer
|
||||
@@ -2,9 +2,13 @@ import string
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import DatabaseError, models, transaction
|
||||
from django.db.models import Max
|
||||
from django.db.models.functions import Cast
|
||||
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
|
||||
|
||||
|
||||
def invoice_filename(instance, filename: str) -> str:
|
||||
@@ -73,11 +77,26 @@ class Invoice(models.Model):
|
||||
is_cancellation = models.BooleanField(default=False)
|
||||
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True, on_delete=models.CASCADE)
|
||||
invoice_from = models.TextField()
|
||||
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_tax_id = models.CharField(max_length=190, null=True)
|
||||
invoice_from_vat_id = models.CharField(max_length=190, null=True)
|
||||
invoice_to = models.TextField()
|
||||
invoice_to_company = models.TextField(null=True)
|
||||
invoice_to_name = models.TextField(null=True)
|
||||
invoice_to_street = models.TextField(null=True)
|
||||
invoice_to_zipcode = models.CharField(max_length=190, null=True)
|
||||
invoice_to_city = models.TextField(null=True)
|
||||
invoice_to_country = CountryField(null=True)
|
||||
invoice_to_vat_id = models.TextField(null=True)
|
||||
invoice_to_beneficiary = models.TextField(null=True)
|
||||
date = models.DateField(default=today)
|
||||
locale = models.CharField(max_length=50, default='en')
|
||||
introductory_text = models.TextField(blank=True)
|
||||
additional_text = models.TextField(blank=True)
|
||||
reverse_charge = models.BooleanField(default=False)
|
||||
payment_provider_text = models.TextField(blank=True)
|
||||
footer_text = models.TextField(blank=True)
|
||||
foreign_currency_display = models.CharField(max_length=50, null=True, blank=True)
|
||||
@@ -92,12 +111,28 @@ class Invoice(models.Model):
|
||||
def _to_numeric_invoice_number(number):
|
||||
return '{:05d}'.format(int(number))
|
||||
|
||||
@property
|
||||
def full_invoice_from(self):
|
||||
parts = [
|
||||
self.invoice_from_name,
|
||||
self.invoice_from,
|
||||
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
||||
str(self.invoice_from_country),
|
||||
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
|
||||
pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id if self.invoice_from_tax_id else "",
|
||||
]
|
||||
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
||||
|
||||
def _get_numeric_invoice_number(self):
|
||||
numeric_invoices = Invoice.objects.filter(
|
||||
event__organizer=self.event.organizer,
|
||||
prefix=self.prefix,
|
||||
).exclude(invoice_no__contains='-')
|
||||
return self._to_numeric_invoice_number(numeric_invoices.count() + 1)
|
||||
).exclude(invoice_no__contains='-').annotate(
|
||||
numeric_number=Cast('invoice_no', models.IntegerField())
|
||||
).aggregate(
|
||||
max=Max('numeric_number')
|
||||
)['max'] or 0
|
||||
return self._to_numeric_invoice_number(numeric_invoices + 1)
|
||||
|
||||
def _get_invoice_number_from_order(self):
|
||||
return '{order}-{count}'.format(
|
||||
@@ -115,6 +150,8 @@ class Invoice(models.Model):
|
||||
if not self.prefix:
|
||||
self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-')
|
||||
if not self.invoice_no:
|
||||
if self.order.testmode:
|
||||
self.prefix += 'TEST-'
|
||||
for i in range(10):
|
||||
if self.event.settings.get('invoice_numbers_consecutive'):
|
||||
self.invoice_no = self._get_numeric_invoice_number()
|
||||
@@ -155,7 +192,10 @@ class Invoice(models.Model):
|
||||
|
||||
class Meta:
|
||||
unique_together = ('organizer', 'prefix', 'invoice_no')
|
||||
ordering = ('invoice_no',)
|
||||
ordering = ('date', 'invoice_no',)
|
||||
|
||||
def __repr__(self):
|
||||
return '<Invoice {} / {}>'.format(self.full_invoice_no, self.pk)
|
||||
|
||||
|
||||
class InvoiceLine(models.Model):
|
||||
@@ -174,6 +214,10 @@ class InvoiceLine(models.Model):
|
||||
:type tax_rate: decimal.Decimal
|
||||
:param tax_name: The name of the applied tax rate
|
||||
:type tax_name: str
|
||||
:param subevent: The subevent this line refers to
|
||||
:type subevent: SubEvent
|
||||
:param event_date_from: Event date of the (sub)event at the time the invoice was created
|
||||
:type event_date_from: datetime
|
||||
"""
|
||||
invoice = models.ForeignKey('Invoice', related_name='lines', on_delete=models.CASCADE)
|
||||
position = models.PositiveIntegerField(default=0)
|
||||
@@ -182,6 +226,8 @@ class InvoiceLine(models.Model):
|
||||
tax_value = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
|
||||
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
|
||||
tax_name = models.CharField(max_length=190)
|
||||
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
|
||||
event_date_from = models.DateTimeField(null=True)
|
||||
|
||||
@property
|
||||
def net_value(self):
|
||||
@@ -189,3 +235,6 @@ class InvoiceLine(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ('position', 'pk')
|
||||
|
||||
def __str__(self):
|
||||
return 'Line {} of invoice {}'.format(self.position, self.invoice)
|
||||
|
||||
@@ -17,6 +17,7 @@ from django.utils.timezone import is_naive, make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.models import fields
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
|
||||
@@ -152,6 +153,30 @@ class SubEventItemVariation(models.Model):
|
||||
self.subevent.event.cache.clear()
|
||||
|
||||
|
||||
class ItemQuerySet(models.QuerySet):
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False):
|
||||
q = (
|
||||
# IMPORTANT: If this is updated, also update the ItemVariation query
|
||||
# in models/event.py: EventMixin.annotated()
|
||||
Q(active=True)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(sales_channels__contains=channel)
|
||||
)
|
||||
if not allow_addons:
|
||||
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
|
||||
qs = self.filter(q)
|
||||
|
||||
vouchq = Q(hide_without_voucher=False)
|
||||
if voucher:
|
||||
if voucher.item_id:
|
||||
vouchq |= Q(pk=voucher.item_id)
|
||||
qs = qs.filter(pk=voucher.item_id)
|
||||
elif voucher.quota_id:
|
||||
qs = qs.filter(quotas__in=[voucher.quota_id])
|
||||
return qs.filter(vouchq)
|
||||
|
||||
|
||||
class Item(LoggedModel):
|
||||
"""
|
||||
An item is a thing which can be sold. It belongs to an event and may or may not belong to a category.
|
||||
@@ -195,8 +220,12 @@ class Item(LoggedModel):
|
||||
:type original_price: decimal.Decimal
|
||||
:param require_approval: If set to ``True``, orders containing this product can only be processed and paid after approved by an administrator
|
||||
:type require_approval: bool
|
||||
:param sales_channels: Sales channels this item is available on.
|
||||
:type sales_channels: bool
|
||||
"""
|
||||
|
||||
objects = ItemQuerySet.as_manager()
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
on_delete=models.PROTECT,
|
||||
@@ -258,6 +287,10 @@ class Item(LoggedModel):
|
||||
),
|
||||
default=False
|
||||
)
|
||||
generate_tickets = models.NullBooleanField(
|
||||
verbose_name=_("Generate tickets"),
|
||||
blank=True, null=True,
|
||||
)
|
||||
position = models.IntegerField(
|
||||
default=0
|
||||
)
|
||||
@@ -298,9 +331,8 @@ class Item(LoggedModel):
|
||||
allow_cancel = models.BooleanField(
|
||||
verbose_name=_('Allow product to be canceled'),
|
||||
default=True,
|
||||
help_text=_('If this is active and the general event settings allow it, orders containing this product can be '
|
||||
'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own '
|
||||
'and you can cancel orders at all times, regardless of this setting')
|
||||
help_text=_('If this is checked, the usual cancellation 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(
|
||||
verbose_name=_('Minimum amount per order'),
|
||||
@@ -329,6 +361,10 @@ class Item(LoggedModel):
|
||||
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
|
||||
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
|
||||
)
|
||||
sales_channels = fields.MultiStringField(
|
||||
verbose_name=_('Sales channels'),
|
||||
default=['web']
|
||||
)
|
||||
# !!! Attention: If you add new fields here, also add them to the copying code in
|
||||
# pretix/control/forms/item.py if applicable.
|
||||
|
||||
@@ -357,17 +393,21 @@ class Item(LoggedModel):
|
||||
rate=Decimal('0.00'), name='')
|
||||
return self.tax_rule.tax(price, base_price_is=base_price_is)
|
||||
|
||||
def is_available_by_time(self, now_dt: datetime=None) -> bool:
|
||||
now_dt = now_dt or now()
|
||||
if self.available_from and self.available_from > now_dt:
|
||||
return False
|
||||
if self.available_until and self.available_until < now_dt:
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_available(self, now_dt: datetime=None) -> bool:
|
||||
"""
|
||||
Returns whether this item is available according to its ``active`` flag
|
||||
and its ``available_from`` and ``available_until`` fields
|
||||
"""
|
||||
now_dt = now_dt or now()
|
||||
if not self.active:
|
||||
return False
|
||||
if self.available_from and self.available_from > now_dt:
|
||||
return False
|
||||
if self.available_until and self.available_until < now_dt:
|
||||
if not self.active or not self.is_available_by_time(now_dt):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -403,12 +443,9 @@ class Item(LoggedModel):
|
||||
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import CartPosition, OrderPosition
|
||||
from pretix.base.models.orders import OrderPosition
|
||||
|
||||
return (
|
||||
not OrderPosition.objects.filter(item=self).exists()
|
||||
and not CartPosition.objects.filter(item=self).exists()
|
||||
)
|
||||
return not OrderPosition.all.filter(item=self).exists()
|
||||
|
||||
@cached_property
|
||||
def has_variations(self):
|
||||
@@ -753,7 +790,7 @@ class Question(LoggedModel):
|
||||
|
||||
@staticmethod
|
||||
def _clean_identifier(event, code, instance=None):
|
||||
qs = Question.objects.filter(event=event, identifier=code)
|
||||
qs = Question.objects.filter(event=event, identifier__iexact=code)
|
||||
if instance:
|
||||
qs = qs.exclude(pk=instance.pk)
|
||||
if qs.exists():
|
||||
@@ -922,6 +959,16 @@ class Quota(LoggedModel):
|
||||
:type size: int
|
||||
:param items: The set of :py:class:`Item` objects this quota applies to
|
||||
:param variations: The set of :py:class:`ItemVariation` objects this quota applies to
|
||||
|
||||
This model keeps a cache of the quota availability that is used in places where up-to-date
|
||||
data is not important. This cache might be out of date even though a more recent quota was
|
||||
calculated. This is intentional to keep database writes low. Currently, the cached values
|
||||
are written whenever the quota is being calculated throughout the system and the cache is
|
||||
at least 120 seconds old or if the new value is qualitatively "better" than the cached one
|
||||
(i.e. more free quota).
|
||||
|
||||
There's also a cronjob that refreshes the cache of every quota if there is any log entry in
|
||||
the event that is newer than the quota's cached time.
|
||||
"""
|
||||
|
||||
AVAILABILITY_GONE = 0
|
||||
@@ -1004,6 +1051,15 @@ class Quota(LoggedModel):
|
||||
This method is used to determine whether Items or ItemVariations belonging
|
||||
to this quota should currently be available for sale.
|
||||
|
||||
:param count_waitinglist: Whether or not take waiting list reservations into account. Defaults
|
||||
to ``True``.
|
||||
:param _cache: A dictionary mapping quota IDs to availabilities. If this quota is already
|
||||
contained in that dictionary, this value will be used. Otherwise, the dict
|
||||
will be populated accordingly.
|
||||
:param allow_cache: Allow for values to be returned from the longer-term cache, see also
|
||||
the documentation of this model class. Only works if ``count_waitinglist`` is
|
||||
set to ``True``.
|
||||
|
||||
:returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants
|
||||
and the second is the number of available tickets.
|
||||
"""
|
||||
@@ -1019,7 +1075,10 @@ class Quota(LoggedModel):
|
||||
res = self._availability(now_dt, count_waitinglist)
|
||||
|
||||
self.event.cache.delete('item_quota_cache')
|
||||
if count_waitinglist and not self.cache_is_hot(now_dt):
|
||||
rewrite_cache = count_waitinglist and (
|
||||
not self.cache_is_hot(now_dt) or res[0] > self.cached_availability_state
|
||||
)
|
||||
if rewrite_cache:
|
||||
self.cached_availability_state = res[0]
|
||||
self.cached_availability_number = res[1]
|
||||
self.cached_availability_time = now_dt
|
||||
@@ -1056,16 +1115,16 @@ class Quota(LoggedModel):
|
||||
|
||||
size_left -= self.count_blocking_vouchers(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
size_left -= self.count_in_cart(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
|
||||
if count_waitinglist:
|
||||
size_left -= self.count_waiting_list_pending()
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
|
||||
size_left -= self.count_in_cart(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
return Quota.AVAILABILITY_OK, size_left
|
||||
|
||||
|
||||
@@ -63,6 +63,16 @@ class LogEntry(models.Model):
|
||||
return response
|
||||
return self.action_type
|
||||
|
||||
@cached_property
|
||||
def organizer(self):
|
||||
if self.event:
|
||||
return self.event.organizer
|
||||
elif hasattr(self.content_object, 'event'):
|
||||
return self.content_object.event.organizer
|
||||
elif hasattr(self.content_object, 'organizer'):
|
||||
return self.content_object.organizer
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def display_object(self):
|
||||
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event, TaxRule, SubEvent
|
||||
|
||||
@@ -26,12 +26,15 @@ from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from .base import LoggedModel
|
||||
from .base import LockModel, LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Question, QuestionOption, Quota
|
||||
|
||||
@@ -47,7 +50,7 @@ def generate_position_secret():
|
||||
return get_random_string(length=settings.ENTROPY['ticket_secret'], allowed_chars='abcdefghjkmnpqrstuvwxyz23456789')
|
||||
|
||||
|
||||
class Order(LoggedModel):
|
||||
class Order(LockModel, LoggedModel):
|
||||
"""
|
||||
An order is created when a user clicks 'buy' on his cart. It holds
|
||||
several OrderPositions and is connected to a user. It has an
|
||||
@@ -68,12 +71,13 @@ class Order(LoggedModel):
|
||||
* ``STATUS_PAID``
|
||||
* ``STATUS_EXPIRED``
|
||||
* ``STATUS_CANCELED``
|
||||
* ``STATUS_REFUNDED``
|
||||
|
||||
:param event: The event this order belongs to
|
||||
:type event: Event
|
||||
:param email: The email of the person who ordered this
|
||||
:type email: str
|
||||
:param testmode: Whether this is a test mode order
|
||||
:type testmode: bool
|
||||
:param locale: The locale of this order
|
||||
:type locale: str
|
||||
:param secret: A secret string that is required to modify the order
|
||||
@@ -92,19 +96,20 @@ class Order(LoggedModel):
|
||||
:type require_approval: bool
|
||||
:param meta_info: Additional meta information on the order, JSON-encoded.
|
||||
:type meta_info: str
|
||||
:param sales_channel: Identifier of the sales channel this order was created through.
|
||||
:type sales_channel: str
|
||||
"""
|
||||
|
||||
STATUS_PENDING = "n"
|
||||
STATUS_PAID = "p"
|
||||
STATUS_EXPIRED = "e"
|
||||
STATUS_CANCELED = "c"
|
||||
STATUS_REFUNDED = "r"
|
||||
STATUS_REFUNDED = "c" # deprecated
|
||||
STATUS_CHOICE = (
|
||||
(STATUS_PENDING, _("pending")),
|
||||
(STATUS_PAID, _("paid")),
|
||||
(STATUS_EXPIRED, _("expired")),
|
||||
(STATUS_CANCELED, _("canceled")),
|
||||
(STATUS_REFUNDED, _("refunded"))
|
||||
)
|
||||
|
||||
code = models.CharField(
|
||||
@@ -118,6 +123,7 @@ class Order(LoggedModel):
|
||||
verbose_name=_("Status"),
|
||||
db_index=True
|
||||
)
|
||||
testmode = models.BooleanField(default=False)
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
verbose_name=_("Event"),
|
||||
@@ -134,7 +140,7 @@ class Order(LoggedModel):
|
||||
)
|
||||
secret = models.CharField(max_length=32, default=generate_secret)
|
||||
datetime = models.DateTimeField(
|
||||
verbose_name=_("Date")
|
||||
verbose_name=_("Date"), db_index=True
|
||||
)
|
||||
expires = models.DateTimeField(
|
||||
verbose_name=_("Expiration date")
|
||||
@@ -172,6 +178,7 @@ class Order(LoggedModel):
|
||||
require_approval = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
sales_channel = models.CharField(max_length=190, default="web")
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Order")
|
||||
@@ -181,6 +188,45 @@ class Order(LoggedModel):
|
||||
def __str__(self):
|
||||
return self.full_code
|
||||
|
||||
def gracefully_delete(self, user=None, auth=None):
|
||||
if not self.testmode:
|
||||
raise TypeError("Only test mode orders can be deleted.")
|
||||
self.event.log_action(
|
||||
'pretix.event.order.deleted', user=user, auth=auth,
|
||||
data={
|
||||
'code': self.code,
|
||||
}
|
||||
)
|
||||
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
|
||||
OrderPosition.all.filter(order=self).delete()
|
||||
OrderFee.all.filter(order=self).delete()
|
||||
self.refunds.all().delete()
|
||||
self.payments.all().delete()
|
||||
self.event.cache.delete('complain_testmode_orders')
|
||||
self.delete()
|
||||
|
||||
@property
|
||||
def fees(self):
|
||||
"""
|
||||
Related manager for all non-canceled fees. Use ``all_fees`` instead if you want
|
||||
canceled positions as well.
|
||||
"""
|
||||
return self.all_fees(manager='objects')
|
||||
|
||||
@cached_property
|
||||
def count_positions(self):
|
||||
if hasattr(self, 'pcnt'):
|
||||
return self.pcnt or 0
|
||||
return self.positions.count()
|
||||
|
||||
@property
|
||||
def positions(self):
|
||||
"""
|
||||
Related manager for all non-canceled positions. Use ``all_positions`` instead if you want
|
||||
canceled positions as well.
|
||||
"""
|
||||
return self.all_positions(manager='objects')
|
||||
|
||||
@cached_property
|
||||
def meta_info_data(self):
|
||||
try:
|
||||
@@ -202,8 +248,8 @@ class Order(LoggedModel):
|
||||
@property
|
||||
def pending_sum(self):
|
||||
total = self.total
|
||||
if self.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
|
||||
total = 0
|
||||
if self.status == Order.STATUS_CANCELED:
|
||||
total = Decimal('0.00')
|
||||
payment_sum = self.payments.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
|
||||
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||
@@ -214,7 +260,7 @@ class Order(LoggedModel):
|
||||
return total - payment_sum + refund_sum
|
||||
|
||||
@classmethod
|
||||
def annotate_overpayments(cls, qs):
|
||||
def annotate_overpayments(cls, qs, results=True, refunds=True, sums=False):
|
||||
payment_sum = OrderPayment.objects.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
order=OuterRef('pk')
|
||||
@@ -232,38 +278,47 @@ class Order(LoggedModel):
|
||||
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
|
||||
order=OuterRef('pk')
|
||||
)
|
||||
payment_sum_sq = Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10))
|
||||
refund_sum_sq = Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10))
|
||||
if sums:
|
||||
qs = qs.annotate(
|
||||
payment_sum=payment_sum_sq,
|
||||
refund_sum=refund_sum_sq,
|
||||
)
|
||||
|
||||
qs = qs.annotate(
|
||||
payment_sum=Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
refund_sum=Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
has_external_refund=Exists(external_refund),
|
||||
has_pending_refund=Exists(pending_refund),
|
||||
).annotate(
|
||||
pending_sum_t=F('total') - Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
|
||||
pending_sum_rc=-1 * Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
|
||||
).annotate(
|
||||
is_overpaid=Case(
|
||||
When(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0),
|
||||
then=Value('1')),
|
||||
When(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
is_pending_with_full_payment=Case(
|
||||
When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
|
||||
& Q(require_approval=False),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
is_underpaid=Case(
|
||||
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=0),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
)
|
||||
pending_sum_t=F('total') - Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
|
||||
pending_sum_rc=-1 * Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
|
||||
)
|
||||
if refunds:
|
||||
qs = qs.annotate(
|
||||
has_external_refund=Exists(external_refund),
|
||||
has_pending_refund=Exists(pending_refund),
|
||||
)
|
||||
if results:
|
||||
qs = qs.annotate(
|
||||
is_overpaid=Case(
|
||||
When(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=-1e-8),
|
||||
then=Value('1')),
|
||||
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=-1e-8),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
is_pending_with_full_payment=Case(
|
||||
When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=1e-8)
|
||||
& Q(require_approval=False),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
is_underpaid=Case(
|
||||
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
)
|
||||
)
|
||||
return qs
|
||||
|
||||
@property
|
||||
@@ -331,10 +386,112 @@ class Order(LoggedModel):
|
||||
|
||||
def cancel_allowed(self):
|
||||
return (
|
||||
self.status == Order.STATUS_PENDING
|
||||
or (self.status == Order.STATUS_PAID and self.total == Decimal('0.00'))
|
||||
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.count_positions
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def user_cancel_deadline(self):
|
||||
if self.status == Order.STATUS_PAID and self.total != Decimal('0.00'):
|
||||
until = self.event.settings.get('cancel_allow_user_paid_until', as_type=RelativeDateWrapper)
|
||||
else:
|
||||
until = self.event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper)
|
||||
if until:
|
||||
if self.event.has_subevents:
|
||||
return min([
|
||||
until.datetime(se)
|
||||
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||
])
|
||||
else:
|
||||
return until.datetime(self.event)
|
||||
|
||||
@cached_property
|
||||
def user_cancel_fee(self):
|
||||
fee = Decimal('0.00')
|
||||
if self.event.settings.cancel_allow_user_paid_keep:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||
if self.event.settings.cancel_allow_user_paid_keep_percentage:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * self.total
|
||||
if self.event.settings.cancel_allow_user_paid_keep_fees:
|
||||
fee += self.fees.filter(
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE)
|
||||
).aggregate(
|
||||
s=Sum('value')
|
||||
)['s'] or 0
|
||||
return round_decimal(fee, self.event.currency)
|
||||
|
||||
@property
|
||||
def user_cancel_allowed(self) -> bool:
|
||||
"""
|
||||
Returns whether or not this order can be canceled by the user.
|
||||
"""
|
||||
from .checkin import Checkin
|
||||
|
||||
positions = list(
|
||||
self.positions.all().annotate(
|
||||
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
|
||||
).select_related('item')
|
||||
)
|
||||
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
|
||||
if not cancelable or not positions:
|
||||
return False
|
||||
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||
return False
|
||||
if self.status == Order.STATUS_PENDING:
|
||||
return self.event.settings.cancel_allow_user
|
||||
elif self.status == Order.STATUS_PAID:
|
||||
if self.total == Decimal('0.00'):
|
||||
return self.event.settings.cancel_allow_user
|
||||
return self.event.settings.cancel_allow_user_paid
|
||||
return False
|
||||
|
||||
def propose_auto_refunds(self, amount: Decimal, payments: list=None):
|
||||
# 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
|
||||
|
||||
unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible)
|
||||
to_refund = amount
|
||||
proposals = {}
|
||||
|
||||
while to_refund and unused_payments:
|
||||
bigger = sorted([
|
||||
p for p in unused_payments
|
||||
if p.available_amount > to_refund
|
||||
and p.partial_refund_possible
|
||||
], key=lambda p: p.available_amount)
|
||||
same = [
|
||||
p for p in unused_payments
|
||||
if p.available_amount == to_refund
|
||||
and (p.full_refund_possible or p.partial_refund_possible)
|
||||
]
|
||||
smaller = sorted([
|
||||
p for p in unused_payments
|
||||
if p.available_amount < to_refund
|
||||
and (p.full_refund_possible or p.partial_refund_possible)
|
||||
], key=lambda p: p.available_amount, reverse=True)
|
||||
if same:
|
||||
payment = same[0]
|
||||
proposals[payment] = payment.available_amount
|
||||
to_refund -= payment.available_amount
|
||||
unused_payments.remove(payment)
|
||||
elif bigger:
|
||||
payment = bigger[0]
|
||||
proposals[payment] = to_refund
|
||||
to_refund -= to_refund
|
||||
unused_payments.remove(payment)
|
||||
elif smaller:
|
||||
payment = smaller[0]
|
||||
proposals[payment] = payment.available_amount
|
||||
to_refund -= payment.available_amount
|
||||
unused_payments.remove(payment)
|
||||
else:
|
||||
break
|
||||
return proposals
|
||||
|
||||
@staticmethod
|
||||
def normalize_code(code):
|
||||
tr = str.maketrans({
|
||||
@@ -353,6 +510,10 @@ class Order(LoggedModel):
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
while True:
|
||||
code = get_random_string(length=settings.ENTROPY['order_code'], allowed_chars=charset)
|
||||
if self.testmode:
|
||||
# Subtle way to recognize test orders while debugging: They all contain a 0 at the second place,
|
||||
# even though zeros are not used outside test mode.
|
||||
code = code[0] + "0" + code[2:]
|
||||
if not Order.objects.filter(event__organizer=self.event.organizer, code=code).exists():
|
||||
self.code = code
|
||||
return
|
||||
@@ -387,15 +548,6 @@ class Order(LoggedModel):
|
||||
|
||||
return False # nothing there to modify
|
||||
|
||||
@property
|
||||
def can_user_cancel(self) -> bool:
|
||||
"""
|
||||
Returns whether or not this order can be canceled by the user.
|
||||
"""
|
||||
positions = self.positions.all().select_related('item')
|
||||
cancelable = all([op.item.allow_cancel for op in positions])
|
||||
return self.cancel_allowed() and self.event.settings.cancel_allow_user and cancelable
|
||||
|
||||
@property
|
||||
def is_expired_by_time(self):
|
||||
return (
|
||||
@@ -456,7 +608,7 @@ class Order(LoggedModel):
|
||||
error_messages = {
|
||||
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
|
||||
"payment settings is over."),
|
||||
'late': _("The payment can not be accepted as it the order is expired and you configured that no late "
|
||||
'late': _("The payment can not be accepted as the order is expired and you configured that no late "
|
||||
"payments should be accepted in the payment settings."),
|
||||
'require_approval': _('This order is not yet approved by the event organizer.')
|
||||
}
|
||||
@@ -533,6 +685,9 @@ class Order(LoggedModel):
|
||||
if not self.email:
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.locale):
|
||||
recipient = self.email
|
||||
try:
|
||||
@@ -553,10 +708,18 @@ class Order(LoggedModel):
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'recipient': recipient,
|
||||
'invoices': [i.pk for i in invoices] if invoices else []
|
||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||
'attach_tickets': attach_tickets,
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def positions_with_tickets(self):
|
||||
for op in self.positions.all():
|
||||
if not op.generate_ticket:
|
||||
continue
|
||||
yield op
|
||||
|
||||
|
||||
def answerfile_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
|
||||
@@ -699,8 +862,10 @@ class AbstractPosition(models.Model):
|
||||
:type expires: datetime
|
||||
:param price: The price of this item
|
||||
:type price: decimal.Decimal
|
||||
:param attendee_name: The attendee's name, if entered.
|
||||
:type attendee_name: str
|
||||
:param attendee_name_parts: The parts of the attendee's name, if entered.
|
||||
:type attendee_name_parts: str
|
||||
:param attendee_name_cached: The concatenated version of the attendee's name, if entered.
|
||||
:type attendee_name_cached: str
|
||||
:param attendee_email: The attendee's email, if entered.
|
||||
:type attendee_email: str
|
||||
:param voucher: A voucher that has been applied to this sale
|
||||
@@ -711,7 +876,7 @@ class AbstractPosition(models.Model):
|
||||
subevent = models.ForeignKey(
|
||||
SubEvent,
|
||||
null=True, blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=pgettext_lazy("subevent", "Date"),
|
||||
)
|
||||
item = models.ForeignKey(
|
||||
@@ -729,22 +894,25 @@ class AbstractPosition(models.Model):
|
||||
decimal_places=2, max_digits=10,
|
||||
verbose_name=_("Price")
|
||||
)
|
||||
attendee_name = models.CharField(
|
||||
attendee_name_cached = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Attendee name"),
|
||||
blank=True, null=True,
|
||||
help_text=_("Empty, if this product is not an admission ticket")
|
||||
)
|
||||
attendee_name_parts = FallbackJSONField(
|
||||
blank=True, default=dict
|
||||
)
|
||||
attendee_email = models.EmailField(
|
||||
verbose_name=_("Attendee email"),
|
||||
blank=True, null=True,
|
||||
help_text=_("Empty, if this product is not an admission ticket")
|
||||
)
|
||||
voucher = models.ForeignKey(
|
||||
'Voucher', null=True, blank=True, on_delete=models.CASCADE
|
||||
'Voucher', null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
addon_to = models.ForeignKey(
|
||||
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons'
|
||||
'self', null=True, blank=True, on_delete=models.PROTECT, related_name='addons'
|
||||
)
|
||||
meta_info = models.TextField(
|
||||
verbose_name=_("Meta information"),
|
||||
@@ -797,6 +965,24 @@ class AbstractPosition(models.Model):
|
||||
if self.variation is None
|
||||
else self.variation.quotas.filter(subevent=self.subevent))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.attendee_name_cached = self.attendee_name
|
||||
if self.attendee_name_parts is None:
|
||||
self.attendee_name_parts = {}
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def attendee_name(self):
|
||||
if not self.attendee_name_parts:
|
||||
return None
|
||||
if '_legacy' in self.attendee_name_parts:
|
||||
return self.attendee_name_parts['_legacy']
|
||||
if '_scheme' in self.attendee_name_parts:
|
||||
scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
|
||||
else:
|
||||
scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
return scheme['concatenation'](self.attendee_name_parts).strip()
|
||||
|
||||
|
||||
class OrderPayment(models.Model):
|
||||
"""
|
||||
@@ -878,6 +1064,9 @@ class OrderPayment(models.Model):
|
||||
class Meta:
|
||||
ordering = ('local_id',)
|
||||
|
||||
def __str__(self):
|
||||
return self.full_id
|
||||
|
||||
@property
|
||||
def info_data(self):
|
||||
"""
|
||||
@@ -901,9 +1090,12 @@ class OrderPayment(models.Model):
|
||||
from pretix.base.signals import order_paid
|
||||
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist)
|
||||
if not force and can_be_paid is not True:
|
||||
self.order.log_action('pretix.event.order.quotaexceeded', {
|
||||
'message': can_be_paid
|
||||
}, user=user, auth=auth)
|
||||
raise Quota.QuotaExceededException(can_be_paid)
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
self.order.save(update_fields=['status'])
|
||||
|
||||
self.order.log_action('pretix.event.order.paid', {
|
||||
'provider': self.provider,
|
||||
@@ -936,9 +1128,23 @@ class OrderPayment(models.Model):
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
self.state = self.PAYMENT_STATE_CONFIRMED
|
||||
self.payment_date = now()
|
||||
self.save()
|
||||
with transaction.atomic():
|
||||
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
|
||||
if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
|
||||
# Race condition detected, this payment is already confirmed
|
||||
return
|
||||
|
||||
locked_instance.state = self.PAYMENT_STATE_CONFIRMED
|
||||
locked_instance.payment_date = now()
|
||||
locked_instance.info = self.info # required for backwards compatibility
|
||||
locked_instance.save(update_fields=['state', 'payment_date', 'info'])
|
||||
|
||||
# Do a cheap manual "refresh from db" on non-complex fields
|
||||
for field in self._meta.concrete_fields:
|
||||
if not field.is_relation:
|
||||
setattr(self, field.attname, getattr(locked_instance, field.attname))
|
||||
|
||||
self.refresh_from_db()
|
||||
|
||||
self.order.log_action('pretix.event.order.payment.confirmed', {
|
||||
'local_id': self.local_id,
|
||||
@@ -1167,6 +1373,9 @@ class OrderRefund(models.Model):
|
||||
class Meta:
|
||||
ordering = ('local_id',)
|
||||
|
||||
def __str__(self):
|
||||
return self.full_id
|
||||
|
||||
@property
|
||||
def info_data(self):
|
||||
"""
|
||||
@@ -1222,11 +1431,19 @@ class OrderRefund(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ActivePositionManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(canceled=False)
|
||||
|
||||
|
||||
class OrderFee(models.Model):
|
||||
"""
|
||||
An OrderFee object represents a fee that is added to the order total independently of
|
||||
the actual positions. This might for example be a payment or a shipping fee.
|
||||
|
||||
The default ``OrderFee.objects`` manager only contains fees that are not ``canceled``. If
|
||||
you ant all objects, you need to use ``OrderFee.all`` instead.
|
||||
|
||||
:param value: Gross price of this fee
|
||||
:type value: Decimal
|
||||
:param order: Order this fee is charged with
|
||||
@@ -1243,16 +1460,20 @@ class OrderFee(models.Model):
|
||||
:type tax_rule: TaxRule
|
||||
:param tax_value: The tax amount included in the price
|
||||
:type tax_value: Decimal
|
||||
:param canceled: True, if this position is canceled and should no longer be regarded
|
||||
:type canceled: bool
|
||||
"""
|
||||
FEE_TYPE_PAYMENT = "payment"
|
||||
FEE_TYPE_SHIPPING = "shipping"
|
||||
FEE_TYPE_SERVICE = "service"
|
||||
FEE_TYPE_CANCELLATION = "cancellation"
|
||||
FEE_TYPE_OTHER = "other"
|
||||
FEE_TYPE_GIFTCARD = "giftcard"
|
||||
FEE_TYPES = (
|
||||
(FEE_TYPE_PAYMENT, _("Payment fee")),
|
||||
(FEE_TYPE_SHIPPING, _("Shipping fee")),
|
||||
(FEE_TYPE_SERVICE, _("Service fee")),
|
||||
(FEE_TYPE_CANCELLATION, _("Cancellation fee")),
|
||||
(FEE_TYPE_OTHER, _("Other fees")),
|
||||
(FEE_TYPE_GIFTCARD, _("Gift card")),
|
||||
)
|
||||
@@ -1264,7 +1485,7 @@ class OrderFee(models.Model):
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
verbose_name=_("Order"),
|
||||
related_name='fees',
|
||||
related_name='all_fees',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
fee_type = models.CharField(
|
||||
@@ -1285,6 +1506,10 @@ class OrderFee(models.Model):
|
||||
max_digits=10, decimal_places=2,
|
||||
verbose_name=_('Tax value')
|
||||
)
|
||||
canceled = models.BooleanField(default=False)
|
||||
|
||||
all = models.Manager()
|
||||
objects = ActivePositionManager()
|
||||
|
||||
@property
|
||||
def net_value(self):
|
||||
@@ -1339,6 +1564,9 @@ class OrderPosition(AbstractPosition):
|
||||
of a specified type (or variation). This has all properties of
|
||||
AbstractPosition.
|
||||
|
||||
The default ``OrderPosition.objects`` manager only contains fees that are not ``canceled``. If
|
||||
you ant all objects, you need to use ``OrderPosition.all`` instead.
|
||||
|
||||
:param order: The order this position is a part of
|
||||
:type order: Order
|
||||
:param positionid: A local ID of this position, counted for each order individually
|
||||
@@ -1351,6 +1579,8 @@ class OrderPosition(AbstractPosition):
|
||||
:type tax_value: Decimal
|
||||
:param secret: The secret used for ticket QR codes
|
||||
:type secret: str
|
||||
:param canceled: True, if this position is canceled and should no longer be regarded
|
||||
:type canceled: bool
|
||||
:param pseudonymization_id: The QR code content for lead scanning
|
||||
:type pseudonymization_id: str
|
||||
"""
|
||||
@@ -1358,7 +1588,7 @@ class OrderPosition(AbstractPosition):
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
verbose_name=_("Order"),
|
||||
related_name='positions',
|
||||
related_name='all_positions',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
@@ -1380,6 +1610,10 @@ class OrderPosition(AbstractPosition):
|
||||
unique=True,
|
||||
db_index=True
|
||||
)
|
||||
canceled = models.BooleanField(default=False)
|
||||
|
||||
all = models.Manager()
|
||||
objects = ActivePositionManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Order position")
|
||||
@@ -1390,6 +1624,15 @@ class OrderPosition(AbstractPosition):
|
||||
def sort_key(self):
|
||||
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0
|
||||
|
||||
@property
|
||||
def generate_ticket(self):
|
||||
if self.item.generate_tickets is not None:
|
||||
return self.item.generate_tickets
|
||||
return (
|
||||
(self.order.event.settings.ticket_download_addons or not self.addon_to_id) and
|
||||
(self.event.settings.ticket_download_nonadm or self.item.admission)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def transform_cart_positions(cls, cp: List, order) -> list:
|
||||
from . import Voucher
|
||||
@@ -1421,6 +1664,7 @@ class OrderPosition(AbstractPosition):
|
||||
# Delete afterwards. Deleting in between might cause deletion of things related to add-ons
|
||||
# due to the deletion cascade.
|
||||
for cartpos in cp:
|
||||
cartpos.addons.all().delete()
|
||||
cartpos.delete()
|
||||
return ops
|
||||
|
||||
@@ -1459,7 +1703,7 @@ class OrderPosition(AbstractPosition):
|
||||
self._calculate_tax()
|
||||
self.order.touch()
|
||||
if self.pk is None:
|
||||
while OrderPosition.objects.filter(secret=self.secret).exists():
|
||||
while OrderPosition.all.filter(secret=self.secret).exists():
|
||||
self.secret = generate_position_secret()
|
||||
|
||||
if not self.pseudonymization_id:
|
||||
@@ -1475,10 +1719,14 @@ class OrderPosition(AbstractPosition):
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
while True:
|
||||
code = get_random_string(length=10, allowed_chars=charset)
|
||||
if not OrderPosition.objects.filter(pseudonymization_id=code).exists():
|
||||
if not OrderPosition.all.filter(pseudonymization_id=code).exists():
|
||||
self.pseudonymization_id = code
|
||||
return
|
||||
|
||||
@property
|
||||
def event(self):
|
||||
return self.order.event
|
||||
|
||||
|
||||
class CartPosition(AbstractPosition):
|
||||
"""
|
||||
@@ -1544,7 +1792,8 @@ class InvoiceAddress(models.Model):
|
||||
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
|
||||
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
|
||||
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
|
||||
name = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||
name_parts = FallbackJSONField(default=dict)
|
||||
street = models.TextField(verbose_name=_('Address'), blank=False)
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
|
||||
@@ -1558,12 +1807,34 @@ class InvoiceAddress(models.Model):
|
||||
help_text=_('This reference will be printed on your invoice for your convenience.'),
|
||||
blank=True
|
||||
)
|
||||
beneficiary = models.TextField(
|
||||
verbose_name=_('Beneficiary'),
|
||||
blank=True
|
||||
)
|
||||
|
||||
def save(self, **kwargs):
|
||||
if self.order:
|
||||
self.order.touch()
|
||||
|
||||
if self.name_parts:
|
||||
self.name_cached = self.name
|
||||
else:
|
||||
self.name_cached = ""
|
||||
self.name_parts = {}
|
||||
super().save(**kwargs)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
if not self.name_parts:
|
||||
return ""
|
||||
if '_legacy' in self.name_parts:
|
||||
return self.name_parts['_legacy']
|
||||
if '_scheme' in self.name_parts:
|
||||
scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
|
||||
else:
|
||||
raise TypeError("Invalid name given.")
|
||||
return scheme['concatenation'](self.name_parts).strip()
|
||||
|
||||
|
||||
def cachedticket_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
|
||||
|
||||
@@ -82,6 +82,20 @@ class Organizer(LoggedModel):
|
||||
|
||||
return ObjectRelatedCache(self)
|
||||
|
||||
def allow_delete(self):
|
||||
from . import Order, Invoice
|
||||
return (
|
||||
not Order.objects.filter(event__organizer=self).exists() and
|
||||
not Invoice.objects.filter(event__organizer=self).exists() and
|
||||
not self.devices.exists()
|
||||
)
|
||||
|
||||
def delete_sub_objects(self):
|
||||
for e in self.events.all():
|
||||
e.delete_sub_objects()
|
||||
e.delete()
|
||||
self.teams.all().delete()
|
||||
|
||||
|
||||
def generate_invite_token():
|
||||
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
|
||||
@@ -97,7 +97,7 @@ class TaxRule(LoggedModel):
|
||||
|
||||
return (
|
||||
not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not OrderPosition.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not self.event.items.filter(tax_rule=self).exists()
|
||||
and self.event.settings.tax_rate_default != self
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from ..decimal import round_decimal
|
||||
from .base import LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Quota
|
||||
from .orders import Order
|
||||
|
||||
|
||||
def _generate_random_code(prefix=None):
|
||||
@@ -182,7 +183,7 @@ class Voucher(LoggedModel):
|
||||
return self.code
|
||||
|
||||
def allow_delete(self):
|
||||
return self.redeemed == 0
|
||||
return self.redeemed == 0 and not self.orderposition_set.exists()
|
||||
|
||||
def clean(self):
|
||||
Voucher.clean_item_properties(
|
||||
@@ -240,6 +241,8 @@ class Voucher(LoggedModel):
|
||||
def clean_quota_needs_checking(data, old_instance, item_changed, creating):
|
||||
# We only need to check for quota on vouchers that are now blocking quota and haven't
|
||||
# before (or have blocked a different quota before)
|
||||
if data.get('allow_ignore_quota', False):
|
||||
return False
|
||||
if data.get('block_quota', False):
|
||||
is_valid = data.get('valid_until') is None or data.get('valid_until') >= now()
|
||||
if not is_valid:
|
||||
@@ -378,3 +381,11 @@ class Voucher(LoggedModel):
|
||||
return p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
|
||||
return p
|
||||
return original_price
|
||||
|
||||
def distinct_orders(self):
|
||||
"""
|
||||
Return the list of orders where this voucher has been used.
|
||||
Each order will appear at most once.
|
||||
"""
|
||||
|
||||
return Order.objects.filter(all_positions__voucher__in=[self]).distinct()
|
||||
|
||||
@@ -159,7 +159,7 @@ class WaitingListEntry(LoggedModel):
|
||||
@staticmethod
|
||||
def clean_duplicate(email, item, variation, subevent, pk):
|
||||
if WaitingListEntry.objects.filter(
|
||||
item=item, variation=variation, email=email, voucher__isnull=True, subevent=subevent
|
||||
item=item, variation=variation, email__iexact=email, voucher__isnull=True, subevent=subevent
|
||||
).exclude(pk=pk).exists():
|
||||
raise ValidationError(_('You are already on this waiting list! We will notify '
|
||||
'you as soon as we have a ticket available for you.'))
|
||||
|
||||
@@ -177,6 +177,7 @@ class ParametrizedOrderNotificationType(NotificationType):
|
||||
n.add_attribute(_('Event'), order.event.name)
|
||||
n.add_attribute(_('Order code'), order.code)
|
||||
n.add_attribute(_('Order total'), money_filter(order.total, logentry.event.currency))
|
||||
n.add_attribute(_('Pending amount'), money_filter(order.pending_sum, logentry.event.currency))
|
||||
n.add_attribute(_('Order date'), date_format(order.datetime, 'SHORT_DATETIME_FORMAT'))
|
||||
n.add_attribute(_('Order status'), order.get_status_display())
|
||||
n.add_attribute(_('Order positions'), str(order.positions.count()))
|
||||
@@ -193,6 +194,12 @@ def register_default_notification_types(sender, **kwargs):
|
||||
_('New order placed'),
|
||||
_('A new order has been placed: {order.code}'),
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.placed.require_approval',
|
||||
_('New order requires approval'),
|
||||
_('A new order has been placed that requires approval: {order.code}'),
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.paid',
|
||||
@@ -225,7 +232,7 @@ def register_default_notification_types(sender, **kwargs):
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.changed',
|
||||
'pretix.event.order.changed.*',
|
||||
_('Order changed'),
|
||||
_('Order {order.code} has been changed.')
|
||||
),
|
||||
@@ -237,9 +244,9 @@ def register_default_notification_types(sender, **kwargs):
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.refunded',
|
||||
_('Order refunded'),
|
||||
_('Order {order.code} has been refunded.')
|
||||
'pretix.event.order.refund.requested',
|
||||
_('Refund requested'),
|
||||
_('You have been requested to issue a refund for {order.code}.')
|
||||
),
|
||||
ActionRequiredNotificationType(
|
||||
sender,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user