forked from CGM_Public/pretix_original
Compare commits
429 Commits
v3.2.0
...
release/3.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d29b8eba01 | ||
|
|
f566b353f2 | ||
|
|
c2221fad32 | ||
|
|
06a8a804f4 | ||
|
|
5832429540 | ||
|
|
7913de971c | ||
|
|
9b91e3e4f6 | ||
|
|
402730df43 | ||
|
|
c1d6d9bf1a | ||
|
|
ddbe27f351 | ||
|
|
35f2b10069 | ||
|
|
0d2a534982 | ||
|
|
a1ad00a30c | ||
|
|
07d2463960 | ||
|
|
08de722525 | ||
|
|
891e740ede | ||
|
|
87645a0b1f | ||
|
|
d7a575683e | ||
|
|
ca83a44489 | ||
|
|
40da03f979 | ||
|
|
fdd45a85f0 | ||
|
|
47579d0517 | ||
|
|
8704a7f3dd | ||
|
|
244e0695b1 | ||
|
|
8e2821b398 | ||
|
|
b738e3bd9d | ||
|
|
fa224fd17e | ||
|
|
76359c859f | ||
|
|
ff98ae3200 | ||
|
|
2ffb4edee9 | ||
|
|
902f589ee6 | ||
|
|
2a6dc22d7b | ||
|
|
4d9ec7c8e4 | ||
|
|
b84a0e93ae | ||
|
|
534f09bdc6 | ||
|
|
370aa63ead | ||
|
|
fb7e859e72 | ||
|
|
80a7c45e05 | ||
|
|
38a19bb58b | ||
|
|
19873e2a09 | ||
|
|
eb7e938af6 | ||
|
|
614c40596f | ||
|
|
4db56e218e | ||
|
|
ed9b96a41c | ||
|
|
e839dbc7d4 | ||
|
|
982fb0149d | ||
|
|
4597cb9849 | ||
|
|
9f629fc1c9 | ||
|
|
387e1b4998 | ||
|
|
84415864e5 | ||
|
|
82feca6e38 | ||
|
|
28242e52aa | ||
|
|
488ee19b11 | ||
|
|
ba4f00cfc0 | ||
|
|
eb392ebf20 | ||
|
|
3cef690779 | ||
|
|
294fc4735a | ||
|
|
6d17cad529 | ||
|
|
668380cc3f | ||
|
|
3bf8de39a0 | ||
|
|
1986cdf4b9 | ||
|
|
577729a271 | ||
|
|
9033e5b6f7 | ||
|
|
c1fa0d1559 | ||
|
|
e1a4dd6e43 | ||
|
|
089a468a5d | ||
|
|
018d345008 | ||
|
|
529e2a0286 | ||
|
|
1d36ef3c24 | ||
|
|
282ad2c869 | ||
|
|
e67ff83378 | ||
|
|
21be22e489 | ||
|
|
3da79ad36b | ||
|
|
8a17fedaa6 | ||
|
|
7d6b3e7140 | ||
|
|
f80ba365a5 | ||
|
|
8156cdd539 | ||
|
|
cd55146867 | ||
|
|
3cb7482bae | ||
|
|
99f3db04a9 | ||
|
|
352942b7d6 | ||
|
|
6d3ccc0182 | ||
|
|
49b73fc096 | ||
|
|
24b931e1c3 | ||
|
|
1c99e01af9 | ||
|
|
66183e805e | ||
|
|
d33c9332c6 | ||
|
|
2284def607 | ||
|
|
15c25a5a0d | ||
|
|
cf5ac6af4b | ||
|
|
2a929200b5 | ||
|
|
3f77d34026 | ||
|
|
a395b24b80 | ||
|
|
984ef60099 | ||
|
|
5b6f0df963 | ||
|
|
509c7d98cc | ||
|
|
3bd4959efe | ||
|
|
4faaa8e521 | ||
|
|
0e8832fd54 | ||
|
|
4faa76d9c7 | ||
|
|
8d1f9bf0f3 | ||
|
|
4afef62cbd | ||
|
|
3d5cfdd9c7 | ||
|
|
b3b1d09690 | ||
|
|
381ecd6d75 | ||
|
|
a12fea71e5 | ||
|
|
a6dd6ac537 | ||
|
|
c3041aa8c4 | ||
|
|
e275677a0a | ||
|
|
fff14c31ba | ||
|
|
a74bde60eb | ||
|
|
12b9d23efb | ||
|
|
afec39ce57 | ||
|
|
4ae22c4a1e | ||
|
|
a928d8c781 | ||
|
|
27b7a756fb | ||
|
|
7252167cc7 | ||
|
|
d6ac337bde | ||
|
|
aa9d3ab614 | ||
|
|
186fb851e6 | ||
|
|
97f7507dad | ||
|
|
e4582dd9ed | ||
|
|
090d5bfe94 | ||
|
|
b4e98e21a1 | ||
|
|
4571abe496 | ||
|
|
f86520af37 | ||
|
|
1a5b8a2e07 | ||
|
|
dddf91d3bf | ||
|
|
f88a09a78c | ||
|
|
adebcc31d4 | ||
|
|
5645a07cdc | ||
|
|
1d732e9675 | ||
|
|
61de010bbb | ||
|
|
22eb9d3493 | ||
|
|
4e8bda0c96 | ||
|
|
0c83e5a50c | ||
|
|
745d7f74a1 | ||
|
|
8c86169d3f | ||
|
|
3c87272fdc | ||
|
|
f1142560f6 | ||
|
|
d46278f04f | ||
|
|
098b7363e6 | ||
|
|
11d0c37415 | ||
|
|
99607aa74a | ||
|
|
d44b75388e | ||
|
|
531c8aedc2 | ||
|
|
0474651070 | ||
|
|
fd7ad3cb16 | ||
|
|
d5b932e0d9 | ||
|
|
5462e256ac | ||
|
|
46d4d97c13 | ||
|
|
8b5241d520 | ||
|
|
ee06844b0d | ||
|
|
d4a491fc1b | ||
|
|
bb3348022f | ||
|
|
a10082a46f | ||
|
|
ef555eaff1 | ||
|
|
31adc37599 | ||
|
|
1d02764be2 | ||
|
|
f80f2e9bf9 | ||
|
|
f5e7e0e309 | ||
|
|
bbc70447a2 | ||
|
|
530632d624 | ||
|
|
370130f047 | ||
|
|
14575693b8 | ||
|
|
436dcc68f2 | ||
|
|
b49e02d988 | ||
|
|
037644421b | ||
|
|
654faa5a10 | ||
|
|
53a22e0e88 | ||
|
|
f8a080d180 | ||
|
|
b013737d70 | ||
|
|
24d6816dac | ||
|
|
2eb92bef9b | ||
|
|
8d5b4d190c | ||
|
|
50dabb5b26 | ||
|
|
84fb25e4d9 | ||
|
|
ee4f75c2fb | ||
|
|
1e63eb743c | ||
|
|
09477e1431 | ||
|
|
2be49d8cab | ||
|
|
808ccfee75 | ||
|
|
485766e247 | ||
|
|
bab27f5ab6 | ||
|
|
26141da1b3 | ||
|
|
4c6cf63d68 | ||
|
|
24fa251b05 | ||
|
|
d65eaf7b95 | ||
|
|
e24a7f3887 | ||
|
|
ec66b20798 | ||
|
|
2c3c9a4ba1 | ||
|
|
4c8e1cef7f | ||
|
|
180963c787 | ||
|
|
ee96fb2ca1 | ||
|
|
5563259b11 | ||
|
|
e7b86e0deb | ||
|
|
067c3b1abc | ||
|
|
3fff04bcda | ||
|
|
150ebc7ddb | ||
|
|
2f906bae5c | ||
|
|
82d1927fe7 | ||
|
|
cde87b437a | ||
|
|
9cbf3490cf | ||
|
|
56cfdfc5b3 | ||
|
|
8564d20183 | ||
|
|
c3155ad16d | ||
|
|
7fce062c2a | ||
|
|
05311fda54 | ||
|
|
0396c851ed | ||
|
|
1d2094f5ea | ||
|
|
44b8531614 | ||
|
|
5e9610eecf | ||
|
|
1c40351b27 | ||
|
|
3a4196c087 | ||
|
|
080b5f02cb | ||
|
|
21c052918c | ||
|
|
9515249098 | ||
|
|
a0dd495cc3 | ||
|
|
46ce9904cd | ||
|
|
600310da0f | ||
|
|
3b306de1bb | ||
|
|
a2c1c69d7e | ||
|
|
f79df47b78 | ||
|
|
1703f3d636 | ||
|
|
580ae7a2e9 | ||
|
|
34703cf6de | ||
|
|
1999495638 | ||
|
|
00798d9207 | ||
|
|
ac2ff6c2aa | ||
|
|
35ac2f0d2b | ||
|
|
3432bb089a | ||
|
|
69b03b24a1 | ||
|
|
520dd3e254 | ||
|
|
d98fce3594 | ||
|
|
aa5ae8b2bd | ||
|
|
b148182240 | ||
|
|
339d7f06ed | ||
|
|
b876293453 | ||
|
|
54091b9721 | ||
|
|
06a744ea2d | ||
|
|
c24ab642ce | ||
|
|
2ab3e1d420 | ||
|
|
0e9d2cfc10 | ||
|
|
63a46d0156 | ||
|
|
6896682dd1 | ||
|
|
384e7f8fc1 | ||
|
|
d403b3d433 | ||
|
|
552e523229 | ||
|
|
95ca6da74a | ||
|
|
bce8879804 | ||
|
|
a05cce8af7 | ||
|
|
dd6f1ee70f | ||
|
|
ad91ed4a30 | ||
|
|
e3c3154469 | ||
|
|
54cd38bbaa | ||
|
|
8dbba1cfd3 | ||
|
|
0bf3ee1a6f | ||
|
|
da7e1dee3e | ||
|
|
df3cc1499f | ||
|
|
2d066e3b5e | ||
|
|
25cfa8973c | ||
|
|
3f7f04ed21 | ||
|
|
88b2f4738a | ||
|
|
ea71b1f04e | ||
|
|
03a06b6997 | ||
|
|
1cd319357d | ||
|
|
4c653aa4f1 | ||
|
|
5b4e27c47b | ||
|
|
919afb94c4 | ||
|
|
8f112f8d9a | ||
|
|
d15e37d93e | ||
|
|
47cf019079 | ||
|
|
f51521143b | ||
|
|
a0ca76f0ec | ||
|
|
d7f71948c1 | ||
|
|
7d72611851 | ||
|
|
2e9e5a8994 | ||
|
|
c8289877bb | ||
|
|
bb0ad6fb11 | ||
|
|
6dcdb87725 | ||
|
|
c2c8f36724 | ||
|
|
11c0a38665 | ||
|
|
c1c5388923 | ||
|
|
c7054fe72d | ||
|
|
13be3b7899 | ||
|
|
d9fe3e51da | ||
|
|
e381e50fbf | ||
|
|
73a323528b | ||
|
|
3a0aaa3f92 | ||
|
|
2a9c105e51 | ||
|
|
038533ad63 | ||
|
|
fcf9f0054e | ||
|
|
621b0c8c95 | ||
|
|
4a40312a32 | ||
|
|
b156efaae8 | ||
|
|
f473439f77 | ||
|
|
9ed49fb379 | ||
|
|
70e95b42fa | ||
|
|
1e0e8184c8 | ||
|
|
161f4a8132 | ||
|
|
8ac978062f | ||
|
|
619435d0f6 | ||
|
|
7e9e03cef4 | ||
|
|
dc3e76d6d3 | ||
|
|
df2c9bdce5 | ||
|
|
20a6decb3a | ||
|
|
3d31b95201 | ||
|
|
cc970caad8 | ||
|
|
845f231446 | ||
|
|
538d1b6b58 | ||
|
|
601fefc57f | ||
|
|
645b9696b7 | ||
|
|
ee5ce900d2 | ||
|
|
ab0be57106 | ||
|
|
ee6c4551b3 | ||
|
|
d63956fe25 | ||
|
|
27fb9cd8b0 | ||
|
|
ada2de8bc9 | ||
|
|
7fe3ac3b5e | ||
|
|
13cf24d7fa | ||
|
|
6d95603aa8 | ||
|
|
97fbec804b | ||
|
|
8f16145dda | ||
|
|
3f5e835367 | ||
|
|
fe92af10fb | ||
|
|
5e88dcf329 | ||
|
|
d7c8460c28 | ||
|
|
837775c8d4 | ||
|
|
2a51969b04 | ||
|
|
357972c8f8 | ||
|
|
08460f9918 | ||
|
|
277b7ad71c | ||
|
|
d0340044d3 | ||
|
|
8abaa16fab | ||
|
|
5e4a16bd44 | ||
|
|
b219faafaa | ||
|
|
3006d35622 | ||
|
|
a879a86dff | ||
|
|
926b94c88f | ||
|
|
638f1324fe | ||
|
|
be7fea4e2e | ||
|
|
adf7b6a4ed | ||
|
|
fc4c6444cb | ||
|
|
03c760c2bb | ||
|
|
27538d220e | ||
|
|
19b10e3ca4 | ||
|
|
2b18621c76 | ||
|
|
e8a2f7e349 | ||
|
|
a326ee8f75 | ||
|
|
147b90b9e8 | ||
|
|
1a461531eb | ||
|
|
f854a0f8d6 | ||
|
|
137ed5da6a | ||
|
|
6695fef3ee | ||
|
|
4a8827cc2b | ||
|
|
328b4ec633 | ||
|
|
974a84fefe | ||
|
|
94b6b86696 | ||
|
|
8fe9b35dea | ||
|
|
2c87d5ece3 | ||
|
|
f8433b5cc9 | ||
|
|
0a200de41c | ||
|
|
25a998a510 | ||
|
|
a1c7a6f2b0 | ||
|
|
1fe93ac6b7 | ||
|
|
4b2f25ce8a | ||
|
|
33cee65d7f | ||
|
|
302966808e | ||
|
|
767e679140 | ||
|
|
1a07686b79 | ||
|
|
7b4c3a00a0 | ||
|
|
7db87af754 | ||
|
|
da9c30089a | ||
|
|
89a85392a9 | ||
|
|
02b6cc5aad | ||
|
|
b3e6f44027 | ||
|
|
ac2df35db6 | ||
|
|
ac212b798d | ||
|
|
195d418e00 | ||
|
|
ba286d96cb | ||
|
|
9842fcf7da | ||
|
|
e97ae04581 | ||
|
|
346f215c50 | ||
|
|
e099fad0ca | ||
|
|
4aeada0bfb | ||
|
|
73dd94fe73 | ||
|
|
8c50b7409f | ||
|
|
b07d9d167d | ||
|
|
f22d5915ea | ||
|
|
e37d85f517 | ||
|
|
c68f715e07 | ||
|
|
db71ec92be | ||
|
|
85e7a16880 | ||
|
|
ed370fa913 | ||
|
|
f7f00fe735 | ||
|
|
37b05fb4f4 | ||
|
|
defd00ffdf | ||
|
|
9f44d7bbf5 | ||
|
|
28e27e91b2 | ||
|
|
e519a50805 | ||
|
|
b11b79b559 | ||
|
|
88378be14e | ||
|
|
8a8f8ae10a | ||
|
|
404d88a220 | ||
|
|
a48d461c22 | ||
|
|
5e59d41f6e | ||
|
|
7fb77eef34 | ||
|
|
f1321b67f9 | ||
|
|
8a6a515b6a | ||
|
|
e34511b984 | ||
|
|
fa3b40f53c | ||
|
|
b870dde301 | ||
|
|
a4d8c810ce | ||
|
|
4152ee4e50 | ||
|
|
7eb5b09b84 | ||
|
|
2f7c16d18b | ||
|
|
49bff3cc33 | ||
|
|
80c9d1ff9e | ||
|
|
dc51d51338 | ||
|
|
8aa8a2265f | ||
|
|
971275e774 | ||
|
|
91b586ce08 | ||
|
|
f10d1bd236 | ||
|
|
eafed2e213 | ||
|
|
02c81e0fa7 | ||
|
|
8e9a5e371c | ||
|
|
25345275c7 | ||
|
|
b9a911dd97 | ||
|
|
361488f3e6 |
14
.travis.sh
14
.travis.sh
@@ -14,18 +14,18 @@ if [ "$PRETIX_CONFIG_FILE" == "tests/travis_postgres.cfg" ]; then
|
||||
fi
|
||||
|
||||
if [ "$1" == "style" ]; then
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt
|
||||
XDG_CACHE_HOME=/cache pip3 install --no-use-pep517 -Ur src/requirements.txt -r src/requirements/dev.txt
|
||||
cd src
|
||||
flake8 .
|
||||
isort -c -rc -df .
|
||||
fi
|
||||
if [ "$1" == "doctests" ]; then
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt
|
||||
XDG_CACHE_HOME=/cache pip3 install --no-use-pep517 -Ur doc/requirements.txt
|
||||
cd doc
|
||||
make doctest
|
||||
fi
|
||||
if [ "$1" == "doc-spelling" ]; then
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt
|
||||
XDG_CACHE_HOME=/cache pip3 install --no-use-pep517 -Ur doc/requirements.txt
|
||||
cd doc
|
||||
make spelling
|
||||
if [ -s _build/spelling/output.txt ]; then
|
||||
@@ -33,26 +33,26 @@ if [ "$1" == "doc-spelling" ]; then
|
||||
fi
|
||||
fi
|
||||
if [ "$1" == "translation-spelling" ]; then
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements/dev.txt
|
||||
XDG_CACHE_HOME=/cache pip3 install --no-use-pep517 -Ur src/requirements/dev.txt
|
||||
cd src
|
||||
potypo
|
||||
fi
|
||||
if [ "$1" == "tests" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
py.test --reruns 5 -n 3 tests
|
||||
fi
|
||||
if [ "$1" == "tests-cov" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
coverage run -m py.test --reruns 5 tests && codecov
|
||||
fi
|
||||
if [ "$1" == "plugins" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt
|
||||
cd src
|
||||
python setup.py develop
|
||||
make all compress
|
||||
|
||||
@@ -3,7 +3,7 @@ Contributing to pretix
|
||||
|
||||
Hey there and welcome to pretix!
|
||||
|
||||
We've got an contributors guide in [our documentation](https://docs.pretix.eu/en/latest/development/contribution/)
|
||||
We've got a contributors guide in [our documentation](https://docs.pretix.eu/en/latest/development/contribution/)
|
||||
together with notes on the [development setup](https://docs.pretix.eu/en/latest/development/setup.html).
|
||||
|
||||
Please note that we have a [Code of Conduct](https://docs.pretix.eu/en/latest/development/contribution/codeofconduct.html)
|
||||
|
||||
@@ -57,6 +57,9 @@ Example::
|
||||
A comma-separated list of plugins that are not available even though they are installed.
|
||||
Defaults to an empty string.
|
||||
|
||||
``auth_backends``
|
||||
A comma-separated list of available auth backends. Defaults to ``pretix.base.auth.NativeAuthBackend``.
|
||||
|
||||
``cookie_domain``
|
||||
The cookie domain to be set. Defaults to ``None``.
|
||||
|
||||
@@ -87,6 +90,11 @@ Example::
|
||||
proxy that actively removes and re-adds the header to make sure the correct client IP is the first value.
|
||||
Defaults to ``off``.
|
||||
|
||||
``trust_x_forwarded_proto``
|
||||
Specifies whether the ``X-Forwarded-Proto`` header can be trusted. Only set to ``on`` if you have a reverse
|
||||
proxy that actively removes and re-adds the header to make sure the correct client IP is the first value.
|
||||
Defaults to ``off``.
|
||||
|
||||
|
||||
Locale settings
|
||||
---------------
|
||||
|
||||
@@ -125,6 +125,8 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
; DO NOT change the following value, it has to be set to the location of the
|
||||
; directory *inside* the docker container
|
||||
datadir=/data
|
||||
trust_x_forwarded_for=on
|
||||
trust_x_forwarded_proto=on
|
||||
|
||||
[database]
|
||||
; Replace postgresql with mysql for MySQL
|
||||
@@ -276,7 +278,7 @@ choice)::
|
||||
|
||||
Then, go to that directory and build the image::
|
||||
|
||||
$ docker build -t mypretix
|
||||
$ docker build . -t mypretix
|
||||
|
||||
You can now use that image ``mypretix`` instead of ``pretix/standalone`` in your service file (see above). Be sure
|
||||
to re-build your custom image after you pulled ``pretix/standalone`` if you want to perform an update.
|
||||
|
||||
@@ -68,7 +68,7 @@ generated key and installs the plugin from the URL we told you::
|
||||
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" && \
|
||||
DJANGO_SETTINGS_MODULE=pretix.settings pip3 install -U "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
|
||||
|
||||
@@ -85,6 +85,8 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
url=https://pretix.mydomain.com
|
||||
currency=EUR
|
||||
datadir=/var/pretix/data
|
||||
trust_x_forwarded_for=on
|
||||
trust_x_forwarded_proto=on
|
||||
|
||||
[database]
|
||||
; For MySQL, replace with "mysql"
|
||||
|
||||
@@ -194,6 +194,7 @@ Cart position endpoints
|
||||
* ``subevent`` (optional)
|
||||
* ``expires`` (optional)
|
||||
* ``includes_tax`` (optional)
|
||||
* ``sales_channel`` (optional)
|
||||
* ``answers``
|
||||
|
||||
* ``question``
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.. spelling::
|
||||
|
||||
geo
|
||||
lat
|
||||
lon
|
||||
|
||||
Events
|
||||
======
|
||||
|
||||
@@ -25,6 +31,8 @@ is_public boolean If ``true``, th
|
||||
presale_start datetime The date at which the ticket shop opens (or ``null``)
|
||||
presale_end datetime The date at which the ticket shop closes (or ``null``)
|
||||
location multi-lingual string The event location (or ``null``)
|
||||
geo_lat float Latitude of the location (or ``null``)
|
||||
geo_lon float Longitude of the location (or ``null``)
|
||||
has_subevents boolean ``true`` if the event series feature is active for this
|
||||
event. Cannot change after event is created.
|
||||
meta_data object Values set for organizer-specific meta data parameters.
|
||||
@@ -34,6 +42,7 @@ seating_plan integer If reserved sea
|
||||
plan. Otherwise ``null``.
|
||||
seat_category_mapping object An object mapping categories of the seating plan
|
||||
(strings) to items in the event (integers or ``null``).
|
||||
timezone string Event timezone name
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -62,9 +71,21 @@ seat_category_mapping object An object mappi
|
||||
|
||||
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
|
||||
The attributes ``geo_lat`` and ``geo_lon`` have been added.
|
||||
|
||||
.. versionchanged:: 3.4
|
||||
|
||||
The attribute ``timezone`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
|
||||
The events resource can now be filtered by meta data attributes.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/
|
||||
|
||||
Returns a list of all events within a given organizer the authenticated user/token has access to.
|
||||
@@ -105,10 +126,13 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer"
|
||||
"pretix.plugins.stripe"
|
||||
@@ -129,6 +153,10 @@ Endpoints
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_from`` and
|
||||
``slug``. Keep in mind that ``date_from`` of event series does not really tell you anything.
|
||||
Default: ``slug``.
|
||||
:query array attr[meta_data_key]: By providing the key and value of a meta data attribute, the list of events will
|
||||
only contain the events matching the set criteria. Providing ``?attr[Format]=Seminar`` would return only those
|
||||
events having set their ``Format`` meta data to ``Seminar``, ``?attr[Format]=`` only those, that have no value
|
||||
set. Please note that this filter will respect default values set on organizer level.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
@@ -169,10 +197,13 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"has_subevents": false,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"meta_data": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer"
|
||||
"pretix.plugins.stripe"
|
||||
@@ -220,8 +251,11 @@ Endpoints
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
@@ -249,10 +283,13 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
@@ -268,11 +305,11 @@ Endpoints
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/clone/
|
||||
|
||||
Creates a new event with properties as set in the request body. The properties that are copied are: 'is_public',
|
||||
`testmode`, settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
|
||||
Creates a new event with properties as set in the request body. The properties that are copied are: ``is_public``,
|
||||
``testmode``, ``has_subevents``, settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
|
||||
|
||||
If the 'plugins' and/or 'is_public' fields are present in the post body this will determine their value. Otherwise
|
||||
their value will be copied from the existing event.
|
||||
If the ``plugins``, ``has_subevents`` and/or ``is_public`` fields are present in the post body this will determine their
|
||||
value. Otherwise their value will be copied from the existing event.
|
||||
|
||||
Please note that you can only copy from events under the same organizer.
|
||||
|
||||
@@ -300,10 +337,13 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
@@ -331,10 +371,13 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"has_subevents": false,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"meta_data": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
@@ -394,10 +437,13 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"has_subevents": false,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"meta_data": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer",
|
||||
"pretix.plugins.stripe",
|
||||
|
||||
234
doc/api/resources/giftcards.rst
Normal file
234
doc/api/resources/giftcards.rst
Normal file
@@ -0,0 +1,234 @@
|
||||
.. _`rest-giftcards`:
|
||||
|
||||
Gift cards
|
||||
==========
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The gift card resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the gift card
|
||||
secret string Gift card code (can not be modified later)
|
||||
value money (string) Current gift card value
|
||||
currency string Currency of the value (can not be modified later)
|
||||
testmode boolean Whether this is a test gift card
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/giftcards/
|
||||
|
||||
Returns a list of all gift cards issued by a given organizer.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/giftcards/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"secret": "HLBYVELFRC77NCQY",
|
||||
"currency": "EUR",
|
||||
"testmode": false,
|
||||
"value": "13.37"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
: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)/giftcards/(id)/
|
||||
|
||||
Returns information on one gift card, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/giftcards/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"secret": "HLBYVELFRC77NCQY",
|
||||
"currency": "EUR",
|
||||
"testmode": false,
|
||||
"value": "13.37"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param id: The ``id`` field of the gift card 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)/giftcards/
|
||||
|
||||
Creates a new gift card
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/giftcards/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"secret": "HLBYVELFRC77NCQY",
|
||||
"currency": "EUR",
|
||||
"value": "13.37"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"secret": "HLBYVELFRC77NCQY",
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"value": "13.37"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a gift card for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The gift card 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)/giftcards/(id)/
|
||||
|
||||
Update a gift card. 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``, ``secret``, ``testmode``, and ``currency`` fields. Be
|
||||
careful when modifying the ``value`` field to avoid race conditions. We recommend to use the ``transact`` method
|
||||
described below.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/giftcards/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"value": "14.00"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"secret": "HLBYVELFRC77NCQY",
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"value": "14.00"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the gift card to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The gift card 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:post:: /api/v1/organizers/(organizer)/giftcards/(id)/transact/
|
||||
|
||||
Atomically change the value of a gift card. A positive amount will increase the value of the gift card,
|
||||
a negative amount will decrease it.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/giftcards/1/transact/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"value": "2.00"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"secret": "HLBYVELFRC77NCQY",
|
||||
"currency": "EUR",
|
||||
"testmode": false,
|
||||
"value": "15.37"
|
||||
}
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
This endpoint now returns status code ``409`` if the transaction would lead to a negative gift card value.
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the gift card to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The gift card 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.
|
||||
:statuscode 409: There is not sufficient credit on the gift card.
|
||||
@@ -21,6 +21,7 @@ Resources and endpoints
|
||||
vouchers
|
||||
checkinlists
|
||||
waitinglist
|
||||
giftcards
|
||||
carts
|
||||
webhooks
|
||||
seatingplans
|
||||
|
||||
@@ -28,6 +28,7 @@ payment_provider_text string Text to be prin
|
||||
payment information
|
||||
footer_text string Text to be printed in the page footer area
|
||||
lines list of objects The actual invoice contents
|
||||
├ position integer Number of the line within an invoice.
|
||||
├ description string Text representing the invoice line (e.g. product name)
|
||||
├ gross_value money (string) Price including taxes
|
||||
├ tax_value money (string) Tax amount included
|
||||
@@ -63,6 +64,11 @@ internal_reference string Customer's refe
|
||||
The attribute ``internal_reference`` has been added.
|
||||
|
||||
|
||||
.. versionchanged:: 3.4
|
||||
|
||||
The attribute ``lines.number`` has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -107,6 +113,7 @@ Endpoints
|
||||
"footer_text": "Big Events LLC - Registration No. 123456 - VAT ID: EU0987654321",
|
||||
"lines": [
|
||||
{
|
||||
"position": 1,
|
||||
"description": "Budget Ticket",
|
||||
"gross_value": "23.00",
|
||||
"tax_value": "0.00",
|
||||
@@ -171,6 +178,7 @@ Endpoints
|
||||
"footer_text": "Big Events LLC - Registration No. 123456 - VAT ID: EU0987654321",
|
||||
"lines": [
|
||||
{
|
||||
"position": 1,
|
||||
"description": "Budget Ticket",
|
||||
"gross_value": "23.00",
|
||||
"tax_value": "0.00",
|
||||
|
||||
@@ -77,6 +77,7 @@ generate_tickets boolean If ``false``, t
|
||||
rules apply.
|
||||
allow_waitinglist boolean If ``false``, no waiting list will be shown for this
|
||||
product when it is sold out.
|
||||
issue_giftcard boolean If ``true``, buying this product will yield a gift card.
|
||||
show_quota_left boolean Publicly show how many tickets are still available.
|
||||
If this is ``null``, the event default is used.
|
||||
has_variations boolean Shows whether or not this item has variations.
|
||||
@@ -206,6 +207,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"issue_giftcard": false,
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
@@ -300,6 +302,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"issue_giftcard": false,
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
@@ -375,6 +378,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"issue_giftcard": false,
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
@@ -437,6 +441,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"issue_giftcard": false,
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
@@ -531,6 +536,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"issue_giftcard": false,
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
|
||||
@@ -61,9 +61,10 @@ 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 non-canceled order positions (see below)
|
||||
fees list of objects List of non-canceled fees included in the order total
|
||||
(i.e. payment fees)
|
||||
positions list of objects List of order positions (see below). By default, only
|
||||
non-canceled positions are included.
|
||||
fees list of objects List of fees included in the order total. By default, only
|
||||
non-canceled fees are included.
|
||||
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
|
||||
``other``)
|
||||
├ value money (string) Fee amount
|
||||
@@ -72,7 +73,8 @@ fees list of objects List of non-can
|
||||
can be empty
|
||||
├ tax_rate decimal (string) VAT rate applied for this fee
|
||||
├ tax_value money (string) VAT included in this fee
|
||||
└ tax_rule integer The ID of the used tax rule (or ``null``)
|
||||
├ tax_rule integer The ID of the used tax rule (or ``null``)
|
||||
└ canceled boolean Whether or not this fee has been canceled.
|
||||
downloads list of objects List of ticket download options for order-wise ticket
|
||||
downloading. This might be a multi-page PDF or a ZIP
|
||||
file of tickets for outputs that do not support
|
||||
@@ -131,20 +133,24 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``sales_channel`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.4:
|
||||
.. 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:
|
||||
.. versionchanged:: 2.5
|
||||
|
||||
The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders.
|
||||
|
||||
.. versionchanged:: 3.1:
|
||||
.. versionchanged:: 3.1
|
||||
|
||||
The ``invoice_address.state`` and ``url`` attributes have been added. When creating orders through the API,
|
||||
vouchers are now supported and many fields are now optional.
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The ``order.fees.canceled`` attribute has been added.
|
||||
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
@@ -159,6 +165,8 @@ Field Type Description
|
||||
id integer Internal ID of the order position
|
||||
order string Order code of the order the position belongs to
|
||||
positionid integer Number of the position within the order
|
||||
canceled boolean Whether or not this position has been canceled. Note that
|
||||
by default, only non-canceled positions are shown.
|
||||
item integer ID of the purchased item
|
||||
variation integer ID of the purchased variation (or ``null``)
|
||||
price money (string) Price of this position
|
||||
@@ -219,6 +227,15 @@ pdf_data object Data object req
|
||||
|
||||
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
|
||||
The ``url`` of a ticket ``download`` can now also return a ``text/uri-list`` instead of a file. See
|
||||
:ref:`order-position-ticket-download` for details.
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The attribute ``canceled`` has been added.
|
||||
|
||||
.. _order-payment-resource:
|
||||
|
||||
Order payment resource
|
||||
@@ -285,6 +302,10 @@ List of all orders
|
||||
|
||||
Filtering for emails or order codes is now case-insensitive.
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/
|
||||
|
||||
Returns a list of all orders within a given event.
|
||||
@@ -350,6 +371,7 @@ List of all orders
|
||||
"id": 23442,
|
||||
"order": "ABC12",
|
||||
"positionid": 1,
|
||||
"canceled": false,
|
||||
"item": 1345,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
@@ -422,6 +444,9 @@ List of all orders
|
||||
: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 include_canceled_positions: If set to ``true``, the output will contain canceled order positions. Note that this
|
||||
only affects position-level cancellations, not fully-canceled orders.
|
||||
:query include_canceled_fees: If set to ``true``, the output will contain canceled order fees.
|
||||
: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. Be careful: We only
|
||||
@@ -439,6 +464,10 @@ List of all orders
|
||||
Fetching individual orders
|
||||
--------------------------
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
|
||||
|
||||
Returns information on one order, identified by its order code.
|
||||
@@ -498,6 +527,7 @@ Fetching individual orders
|
||||
"id": 23442,
|
||||
"order": "ABC12",
|
||||
"positionid": 1,
|
||||
"canceled": false,
|
||||
"item": 1345,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
@@ -563,6 +593,9 @@ Fetching individual orders
|
||||
: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 fetch
|
||||
:query include_canceled_positions: If set to ``true``, the output will contain canceled order positions. Note that this
|
||||
only affects position-level cancellations, not fully-canceled orders.
|
||||
:query include_canceled_fees: If set to ``true``, the output will contain canceled order fees.
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
@@ -769,6 +802,8 @@ Creating orders
|
||||
|
||||
* does not support file upload questions
|
||||
|
||||
* does not support redeeming gift cards
|
||||
|
||||
You can supply the following fields of the resource:
|
||||
|
||||
* ``code`` (optional)
|
||||
@@ -785,8 +820,9 @@ Creating orders
|
||||
* ``locale``
|
||||
* ``sales_channel``
|
||||
* ``payment_provider`` (optional) – 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.
|
||||
existing payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"``
|
||||
for all orders you create as paid. This field is optional when the order status is ``"n"`` or the order total is
|
||||
zero, otherwise it is required.
|
||||
* ``payment_info`` (optional) – You can pass a nested JSON object that will be set as the internal ``info``
|
||||
value of the payment object that will be created. How this value is handled is up to the payment provider and you
|
||||
should only use this if you know the specific payment provider in detail. Please keep in mind that the payment
|
||||
@@ -837,6 +873,14 @@ Creating orders
|
||||
* ``description``
|
||||
* ``internal_type``
|
||||
* ``tax_rule``
|
||||
* ``_treat_value_as_percentage`` (Optional convenience flag. If set to ``true``, your ``value`` parameter will
|
||||
be treated as a percentage and the fee will be calculated using that percentage and the sum of all product
|
||||
prices. Note that this will not include other fees and is calculated once during order generation and will not
|
||||
be respected automatically when the order changes later.)
|
||||
* ``_split_taxes_like_products`` (Optional convenience flag. If set to ``true``, your ``tax_rule`` will be ignored
|
||||
and the fee will be taxed like the products in the order. If the products have multiple tax rates, multiple fees
|
||||
will be generated with weights adjusted to the net price of the products. Note that this will be calculated once
|
||||
during order generation and is not respected automatically when the order changes later.)
|
||||
|
||||
* ``force`` (optional). If set to ``true``, quotas will be ignored.
|
||||
* ``send_mail`` (optional). If set to ``true``, the same emails will be sent as for a regular order. Defaults to
|
||||
@@ -1297,8 +1341,9 @@ List of all order positions
|
||||
|
||||
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
.. note:: Individually canceled order positions are currently not visible via the API at all.
|
||||
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
|
||||
|
||||
@@ -1329,6 +1374,7 @@ List of all order positions
|
||||
"id": 23442,
|
||||
"order": "ABC12",
|
||||
"positionid": 1,
|
||||
"canceled": false,
|
||||
"item": 1345,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
@@ -1398,6 +1444,8 @@ List of all order positions
|
||||
comma-separated IDs.
|
||||
:query string voucher: Only return positions with a specific voucher.
|
||||
:query string voucher__code: Only return positions with a specific voucher code.
|
||||
:query include_canceled_positions: If set to ``true``, the output will contain canceled order positions. Note that this
|
||||
only affects position-level cancellations, not fully-canceled orders.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
@@ -1431,6 +1479,7 @@ Fetching individual positions
|
||||
"id": 23442,
|
||||
"order": "ABC12",
|
||||
"positionid": 1,
|
||||
"canceled": false,
|
||||
"item": 1345,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
@@ -1475,11 +1524,14 @@ Fetching individual positions
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the order position to fetch
|
||||
:query include_canceled_positions: If set to ``true``, canceled positions may be returned (otherwise, they return 404).
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order position does not exist.
|
||||
|
||||
.. _`order-position-ticket-download`:
|
||||
|
||||
Order position ticket download
|
||||
------------------------------
|
||||
|
||||
@@ -1489,6 +1541,11 @@ Order position ticket download
|
||||
Depending on the chosen output, the response might be a ZIP file, PDF file or something else. The order details
|
||||
response contains a list of output options for this particular order position.
|
||||
|
||||
Be aware that the output does not have to be a file, but can also be a regular HTTP response with a ``Content-Type``
|
||||
set to ``text/uri-list``. In this case, the user is expected to navigate to that URL in order to access their ticket.
|
||||
The referenced URL can provide a download or a regular, human-viewable website - so it is advised to open this URL
|
||||
in a webbrowser and leave it up to the user to handle the result.
|
||||
|
||||
Tickets can be only downloaded if the order is paid and if ticket downloads are active. Also, depending on event
|
||||
configuration downloads might be only unavailable for add-on products or non-admission products.
|
||||
Note that in some cases the ticket file might not yet have been created. In that case, you will receive a status
|
||||
|
||||
@@ -18,6 +18,7 @@ Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the question
|
||||
question multi-lingual string The field label shown to the customer
|
||||
help_text multi-lingual string The help text shown to the customer
|
||||
type string The expected type of answer. Valid options:
|
||||
|
||||
* ``N`` – number
|
||||
@@ -31,6 +32,7 @@ type string The expected ty
|
||||
* ``H`` – time
|
||||
* ``W`` – date and time
|
||||
* ``CC`` – country code (ISO 3666-1 alpha-2)
|
||||
* ``TEL`` – telephone number
|
||||
required boolean If ``true``, the question needs to be filled out.
|
||||
position integer An integer, used for sorting
|
||||
items list of integers List of item IDs this question is assigned to.
|
||||
@@ -86,6 +88,10 @@ dependency_value string An old version
|
||||
|
||||
The attribute ``print_on_invoice`` has been added.
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The attribute ``help_text`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -122,6 +128,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"question": {"en": "T-Shirt size"},
|
||||
"help_text": {"en": "Choose your preferred t-shirt-size"},
|
||||
"type": "C",
|
||||
"required": false,
|
||||
"items": [1, 2],
|
||||
@@ -192,6 +199,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"question": {"en": "T-Shirt size"},
|
||||
"help_text": {"en": "Choose your preferred t-shirt-size"},
|
||||
"type": "C",
|
||||
"required": false,
|
||||
"items": [1, 2],
|
||||
@@ -247,6 +255,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"question": {"en": "T-Shirt size"},
|
||||
"help_text": {"en": "Choose your preferred t-shirt-size"},
|
||||
"type": "C",
|
||||
"required": false,
|
||||
"items": [1, 2],
|
||||
@@ -281,6 +290,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"question": {"en": "T-Shirt size"},
|
||||
"help_text": {"en": "Choose your preferred t-shirt-size"},
|
||||
"type": "C",
|
||||
"required": false,
|
||||
"items": [1, 2],
|
||||
@@ -355,6 +365,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"question": {"en": "T-Shirt size"},
|
||||
"help_text": {"en": "Choose your preferred t-shirt-size"},
|
||||
"type": "C",
|
||||
"required": false,
|
||||
"items": [1, 2],
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.. spelling::
|
||||
|
||||
geo
|
||||
lat
|
||||
lon
|
||||
|
||||
.. _rest-subevents:
|
||||
|
||||
Event series dates / Sub-events
|
||||
@@ -28,6 +34,8 @@ date_admission datetime The sub-event's
|
||||
presale_start datetime The sub-date at which the ticket shop opens (or ``null``)
|
||||
presale_end datetime The sub-date at which the ticket shop closes (or ``null``)
|
||||
location multi-lingual string The sub-event location (or ``null``)
|
||||
geo_lat float Latitude of the location (or ``null``)
|
||||
geo_lon float Longitude of the location (or ``null``)
|
||||
item_price_overrides list of objects List of items for which this sub-event overrides the
|
||||
default price
|
||||
├ item integer The internal item ID
|
||||
@@ -62,9 +70,17 @@ seat_category_mapping object An object mappi
|
||||
|
||||
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
|
||||
The attributes ``geo_lat`` and ``geo_lon`` have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
|
||||
The sub-events resource can now be filtered by meta data attributes.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/
|
||||
|
||||
Returns a list of all sub-events of an event.
|
||||
@@ -104,6 +120,8 @@ Endpoints
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
@@ -123,6 +141,11 @@ Endpoints
|
||||
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the main event
|
||||
:query array attr[meta_data_key]: By providing the key and value of a meta data attribute, the list of sub-events
|
||||
will only contain the sub-events matching the set criteria. Providing ``?attr[Format]=Seminar`` would return
|
||||
only those sub-events having set their ``Format`` meta data to ``Seminar``, ``?attr[Format]=`` only those, that
|
||||
have no value set. Please note that this filter will respect default values set on
|
||||
organizer or event level.
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
@@ -152,6 +175,8 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"item_price_overrides": [
|
||||
@@ -184,6 +209,8 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"item_price_overrides": [
|
||||
@@ -237,6 +264,8 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"item_price_overrides": [
|
||||
@@ -303,6 +332,8 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"item_price_overrides": [
|
||||
@@ -389,6 +420,8 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"geo_lat": null,
|
||||
"geo_lon": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"item_price_overrides": [
|
||||
|
||||
@@ -38,6 +38,7 @@ quota integer An ID of a quot
|
||||
attached either to a specific product or to all
|
||||
products within one quota or it can be available
|
||||
for all items without restriction.
|
||||
seat string ``seat_guid`` attribute of a specific seat (or ``null``)
|
||||
tag string A string that is used for grouping vouchers
|
||||
comment string An internal comment on the voucher
|
||||
subevent integer ID of the date inside an event series this voucher belongs to (or ``null``).
|
||||
@@ -53,6 +54,10 @@ show_hidden_items boolean Only if set to
|
||||
|
||||
The attribute ``show_hidden_items`` has been added.
|
||||
|
||||
.. versionchanged:: 3.4
|
||||
|
||||
The attribute ``seat`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -96,7 +101,8 @@ Endpoints
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": "",
|
||||
"subevent": null
|
||||
"seat": null,
|
||||
"subevent": null,
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -162,6 +168,7 @@ Endpoints
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": "",
|
||||
"seat": null,
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
@@ -225,6 +232,7 @@ Endpoints
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": "",
|
||||
"seat": null,
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
@@ -352,6 +360,7 @@ Endpoints
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": "",
|
||||
"seat": null,
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
|
||||
12
doc/contents.rst
Normal file
12
doc/contents.rst
Normal file
@@ -0,0 +1,12 @@
|
||||
Table of contents
|
||||
=================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 3
|
||||
|
||||
user/index
|
||||
admin/index
|
||||
api/index
|
||||
development/index
|
||||
plugins/index
|
||||
|
||||
70
doc/development/api/auth.rst
Normal file
70
doc/development/api/auth.rst
Normal file
@@ -0,0 +1,70 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
Pluggable authentication backends
|
||||
=================================
|
||||
|
||||
Plugins can supply additional authentication backends. This is mainly useful in self-hosted installations
|
||||
and allows you to use company-wide login mechanisms such as LDAP or OAuth for accessing pretix' backend.
|
||||
|
||||
Every authentication backend contains an implementation of the interface defined in ``pretix.base.auth.BaseAuthBackend``
|
||||
(see below). Note that pretix authentication backends work differently than plain Django authentication backends.
|
||||
Basically, three pre-defined flows are supported:
|
||||
|
||||
* Authentication mechanisms that rely on a **set of input parameters**, e.g. a username and a password. These can be
|
||||
implemented by supplying the ``login_form_fields`` property and a ``form_authenticate`` method.
|
||||
|
||||
* Authentication mechanisms that rely on **external sessions**, e.g. a cookie or a proxy HTTP header. These can be
|
||||
implemented by supplying a ``request_authenticate`` method.
|
||||
|
||||
* Authentication mechanisms that rely on **redirection**, e.g. to an OAuth provider. These can be implemented by
|
||||
supplying a ``authentication_url`` method and implementing a custom return view.
|
||||
|
||||
Authentication backends are *not* collected through a signal. Instead, they must explicitly be set through the
|
||||
``auth_backends`` directive in the ``pretix.cfg`` :ref:`configuration file <config>`.
|
||||
|
||||
In each of these methods (``form_authenticate``, ``request_authenticate`` or your custom view) you are supposed to
|
||||
either get an existing :py:class:`pretix.base.models.User` object from the database or create a new one. There are a
|
||||
few rules you need to follow:
|
||||
|
||||
* You **MUST** only return users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
|
||||
|
||||
* You **MUST** create new users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
|
||||
|
||||
* Every user object **MUST** have an email address. Email addresses are globally unique. If the email address is
|
||||
already registered to a user who signs in through a different backend, you **SHOULD** refuse the login.
|
||||
|
||||
The backend interface
|
||||
---------------------
|
||||
|
||||
.. class:: pretix.base.auth.BaseAuthBackend
|
||||
|
||||
The central object of each backend is the subclass of ``BaseAuthBackend``.
|
||||
|
||||
.. autoattribute:: identifier
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: verbose_name
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: login_form_fields
|
||||
|
||||
.. autoattribute:: visible
|
||||
|
||||
.. automethod:: form_authenticate
|
||||
|
||||
.. automethod:: request_authenticate
|
||||
|
||||
.. automethod:: authentication_url
|
||||
|
||||
Logging users in
|
||||
----------------
|
||||
|
||||
If you return a user from ``form_authenticate`` or ``request_authenticate``, the system will handle everything else
|
||||
for you correctly. However, if you use a redirection method and build a custom view to verify the login, we strongly
|
||||
recommend that you use the following utility method to correctly set session values and enforce two-factor
|
||||
authentication (if activated):
|
||||
|
||||
.. autofunction:: pretix.control.views.auth.process_login
|
||||
@@ -20,13 +20,13 @@ Order events
|
||||
There are multiple signals that will be sent out in the ordering cycle:
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: validate_cart, validate_cart_addons, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split
|
||||
:members: validate_cart, validate_cart_addons, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text
|
||||
|
||||
Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
: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, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info
|
||||
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, item_description
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
@@ -49,8 +49,8 @@ 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_position_buttons, subevent_forms, item_formsets
|
||||
|
||||
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms,
|
||||
item_formsets, order_search_filter_q
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events
|
||||
@@ -78,3 +78,6 @@ Ticket designs
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: layout_text_variables
|
||||
|
||||
.. automodule:: pretix.plugins.ticketoutputpdf.signals
|
||||
:members: override_layout
|
||||
|
||||
112
doc/development/api/import.rst
Normal file
112
doc/development/api/import.rst
Normal file
@@ -0,0 +1,112 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
.. _`importcol`:
|
||||
|
||||
Extending the order import process
|
||||
==================================
|
||||
|
||||
It's possible through the backend to import orders into pretix, for example from a legacy ticketing system. If your
|
||||
plugins defines additional data structures around orders, it might be useful to make it possible to import them as well.
|
||||
|
||||
Import process
|
||||
--------------
|
||||
|
||||
Here's a short description of pretix' import process to show you where the system will need to interact with your plugin.
|
||||
You can find more detailed descriptions of the attributes and methods further below.
|
||||
|
||||
1. The user uploads a CSV file. The system tries to parse the CSV file and understand its column headers.
|
||||
|
||||
2. A preview of the file is shown to the user and the user is asked to assign the various different input parameters to
|
||||
columns of the file or static values. For example, the user either needs to manually select a product or specify a
|
||||
column that contains a product. For this purpose, a select field is rendered for every possible input column,
|
||||
allowing the user to choose between a default/empty value (defined by your ``default_value``/``default_label``)
|
||||
attributes, the columns of the uploaded file, or a static value (defined by your ``static_choices`` method).
|
||||
|
||||
3. The user submits its assignment and the system uses the ``resolve`` method of all columns to get the raw value for
|
||||
all columns.
|
||||
|
||||
4. The system uses the ``clean`` method of all columns to verify that all input fields are valid and transformed to the
|
||||
correct data type.
|
||||
|
||||
5. The system prepares internal model objects (``Order`` etc) and uses the ``assign`` method of all columns to assign
|
||||
these objects with actual values.
|
||||
|
||||
6. The system saves all of these model objects to the database in a database transaction. Plugins can create additional
|
||||
objects in this stage through their ``save`` method.
|
||||
|
||||
Column registration
|
||||
-------------------
|
||||
|
||||
The import API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available import columns. Your plugin
|
||||
should listen for this signal and return the subclass of ``pretix.base.orderimport.ImportColumn``
|
||||
that we'll provide in this plugin:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.signals import order_import_columns
|
||||
|
||||
|
||||
@receiver(order_import_columns, dispatch_uid="custom_columns")
|
||||
def register_column(sender, **kwargs):
|
||||
return [
|
||||
EmailColumn(sender),
|
||||
]
|
||||
|
||||
The column class API
|
||||
--------------------
|
||||
|
||||
.. class:: pretix.base.orderimport.ImportColumn
|
||||
|
||||
The central object of each import extension is the subclass of ``ImportColumn``.
|
||||
|
||||
.. py:attribute:: ImportColumn.event
|
||||
|
||||
The default constructor sets this property to the event we are currently
|
||||
working for.
|
||||
|
||||
.. autoattribute:: identifier
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: verbose_name
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: default_value
|
||||
|
||||
.. autoattribute:: default_label
|
||||
|
||||
.. autoattribute:: initial
|
||||
|
||||
.. automethod:: static_choices
|
||||
|
||||
.. automethod:: resolve
|
||||
|
||||
.. automethod:: clean
|
||||
|
||||
.. automethod:: assign
|
||||
|
||||
.. automethod:: save
|
||||
|
||||
Example
|
||||
-------
|
||||
|
||||
For example, the import column responsible for assigning email addresses looks like this:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
class EmailColumn(ImportColumn):
|
||||
identifier = 'email'
|
||||
verbose_name = _('E-mail address')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
EmailValidator()(value)
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
order.email = value
|
||||
@@ -15,6 +15,8 @@ Contents:
|
||||
placeholder
|
||||
invoice
|
||||
shredder
|
||||
import
|
||||
customview
|
||||
auth
|
||||
general
|
||||
quality
|
||||
|
||||
@@ -62,6 +62,8 @@ The provider class
|
||||
|
||||
.. autoattribute:: is_enabled
|
||||
|
||||
.. autoattribute:: priority
|
||||
|
||||
.. autoattribute:: settings_form_fields
|
||||
|
||||
.. automethod:: settings_form_clean
|
||||
@@ -108,10 +110,14 @@ The provider class
|
||||
|
||||
.. automethod:: execute_refund
|
||||
|
||||
.. automethod:: refund_control_render
|
||||
|
||||
.. automethod:: api_payment_details
|
||||
|
||||
.. automethod:: shred_payment_info
|
||||
|
||||
.. automethod:: cancel_payment
|
||||
|
||||
.. autoattribute:: is_implicit
|
||||
|
||||
.. autoattribute:: is_meta
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
Writing an HTML e-mail placeholder plugin
|
||||
=========================================
|
||||
Writing an e-mail placeholder plugin
|
||||
====================================
|
||||
|
||||
An email placeholder is a dynamic value that pretix users can use in their email templates.
|
||||
|
||||
|
||||
@@ -69,3 +69,9 @@ The output class
|
||||
.. automethod:: generate_order
|
||||
|
||||
.. autoattribute:: download_button_text
|
||||
|
||||
.. autoattribute:: download_button_icon
|
||||
|
||||
.. autoattribute:: preview_allowed
|
||||
|
||||
.. autoattribute:: javascript_required
|
||||
|
||||
@@ -1,12 +1 @@
|
||||
Table of contents
|
||||
=================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 3
|
||||
|
||||
user/index
|
||||
admin/index
|
||||
api/index
|
||||
development/index
|
||||
plugins/index
|
||||
|
||||
.. include:: contents.rst
|
||||
@@ -31,6 +31,7 @@ deprovision
|
||||
discoverable
|
||||
django
|
||||
dockerfile
|
||||
downloadable
|
||||
durations
|
||||
eu
|
||||
filename
|
||||
@@ -41,6 +42,7 @@ formsets
|
||||
frontend
|
||||
frontpage
|
||||
gettext
|
||||
giftcard
|
||||
gunicorn
|
||||
guid
|
||||
hardcoded
|
||||
@@ -55,6 +57,7 @@ Jimdo
|
||||
libpretixprint
|
||||
libsass
|
||||
linters
|
||||
login
|
||||
memcached
|
||||
metadata
|
||||
middleware
|
||||
@@ -76,6 +79,7 @@ overpayment
|
||||
param
|
||||
passphrase
|
||||
percental
|
||||
pluggable
|
||||
positionid
|
||||
pre
|
||||
prepend
|
||||
@@ -137,6 +141,7 @@ username
|
||||
url
|
||||
versa
|
||||
versioning
|
||||
viewable
|
||||
viewset
|
||||
viewsets
|
||||
waitinglist
|
||||
|
||||
71
doc/user/events/giftcards.rst
Normal file
71
doc/user/events/giftcards.rst
Normal file
@@ -0,0 +1,71 @@
|
||||
.. spelling::
|
||||
|
||||
Warengutschein
|
||||
Wertgutschein
|
||||
|
||||
Gift cards
|
||||
==========
|
||||
|
||||
Gift cards, also known as "gift coupons" or "gift certificates" are a mechanism that allows you to sell tokens that
|
||||
can later be used to pay for tickets.
|
||||
|
||||
Gift cards are very different feature than **vouchers**. The difference is:
|
||||
|
||||
* Vouchers can be used to give a discount. When a voucher is used, the price of a ticket is reduced by the configured
|
||||
discount and sold at a lower price. They therefore reduce both revenue as well as taxes. Vouchers (in pretix) are
|
||||
always specific to a certain product in an order. Vouchers are usually not sold but given out as part of a
|
||||
marketing campaign or to specific groups of people. Vouchers in pretix are bound to a specific event.
|
||||
|
||||
* Gift cards are not a discount, but rather a means of payment. If you buy a €20 ticket with a €10 gift card, it is
|
||||
still a €20 ticket and will still count towards your revenue with €20. Gift cards are usually bought for the money
|
||||
that they are worth. Gift cards in pretix can be used across events (and even organizers).
|
||||
|
||||
Selling gift cards
|
||||
------------------
|
||||
|
||||
Selling gift cards works like selling every other type of product in pretix: Create a new product, then head to
|
||||
"Additional settings" and select the option "This product is a gift card". Whenever someone buys this product and
|
||||
pays for it, a new gift card will be created.
|
||||
|
||||
In this case, the gift card code corresponds to the "ticket secret" in the PDF ticket. Therefore, if selling gift cards,
|
||||
you can use ticket downloads just as with normal tickets and use our ticket editor to create beautiful gift certificates
|
||||
people can give to their loved ones.
|
||||
|
||||
Of course, you can use pretix' flexible options to modify your product. For example, you can configure that the customer
|
||||
can freely choose the price of the gift card.
|
||||
|
||||
.. note::
|
||||
|
||||
pretix currently does not support charging sales tax or VAT when selling gift cards, but instead charges VAT on
|
||||
the full price when the gift card is redeemed. This is the correct behavior in Germany and some other countries for
|
||||
gift cards which are not bound to a very specific service ("Warengutschein"), but instead to a monetary amount
|
||||
("Wertgutschein").
|
||||
|
||||
.. note::
|
||||
|
||||
The ticket PDF will not contain the correct gift card code before the order has been paid, so we recommend not
|
||||
selling gift cards in events where tickets are issued before payments arrive.
|
||||
|
||||
|
||||
Accepting gift cards
|
||||
--------------------
|
||||
|
||||
All your events have have the payment provider "Gift card" enabled by default, but it will only show up in the ticket
|
||||
shop once the very first gift card has been issued on your organizer account. Of course, you can turn off gift card
|
||||
payments if you do not want them for a specific event.
|
||||
|
||||
If gift card payments are enabled, buyers will be able to select "Gift card" as a payment method during checkout. If
|
||||
a gift card with a value less than the order total is used, the buyer will be asked to select a second payment method
|
||||
for the remaining payment. If a gift card with a value greater than the order total is used, the surplus amount
|
||||
remains on the gift card and can be used in a different purchase.
|
||||
|
||||
If it possible to accept gift cards across organizer accounts. To do so, you need to have access to both organizer
|
||||
accounts. Then, you will see a configuration section at the bottom of the "Gift cards" page of your organizer settings
|
||||
where you can specify which gift cards should be accepted.
|
||||
|
||||
Manually issuing or using gift cards
|
||||
------------------------------------
|
||||
|
||||
Of course, you can also issue or redeem gift cards manually through our backend using the "Gift cards" menu item in your
|
||||
organizer profile or using our API. These gift cards will be tracked by pretix, but do not correspond to any purchase
|
||||
within pretix. You will therefore need to account for them in your books separately.
|
||||
@@ -206,6 +206,21 @@ If you want, you can suppress us loading the widget and/or modify the user data
|
||||
|
||||
If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``.
|
||||
|
||||
Waiting for the widget to load
|
||||
------------------------------
|
||||
|
||||
If you want to run custom JavaScript once the widget is fully loaded, you can register a callback function. Note that
|
||||
this function might be run multiple times, for example if you have multiple widgets on a page or if the user switches
|
||||
e.g. from an event list to an event detail view::
|
||||
|
||||
<script type="text/javascript">
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.addLoadListener(function () {
|
||||
console.log("Widget has loaded!");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
Passing user data to the widget
|
||||
-------------------------------
|
||||
@@ -241,6 +256,11 @@ This works for the pretix Button as well. Currently, the following attributes ar
|
||||
naming scheme such as ``name-title`` or ``name-given-name`` (see above). ``country`` expects a two-character
|
||||
country code.
|
||||
|
||||
* If ``data-fix="true"`` is given, the user will not be able to change the other given values later. This currently
|
||||
only works for the order email address as well as the invoice address. Attendee-level fields and questions can
|
||||
always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely
|
||||
on this for authentication.
|
||||
|
||||
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:
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,5 +12,6 @@ wanting to use pretix to sell tickets.
|
||||
events/settings
|
||||
events/structureguide
|
||||
events/widget
|
||||
events/giftcards
|
||||
faq
|
||||
markdown
|
||||
markdown
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.2.0"
|
||||
__version__ = "3.5.0"
|
||||
|
||||
18
src/pretix/api/migrations/0005_auto_20191028_1541.py
Normal file
18
src/pretix/api/migrations/0005_auto_20191028_1541.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.1 on 2019-10-28 15:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixapi', '0004_auto_20190405_1048'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='oauthgrant',
|
||||
name='redirect_uri',
|
||||
field=models.CharField(max_length=2500),
|
||||
),
|
||||
]
|
||||
@@ -43,6 +43,7 @@ class OAuthGrant(AbstractGrant):
|
||||
OAuthApplication, on_delete=models.CASCADE
|
||||
)
|
||||
organizers = models.ManyToManyField('pretixbase.Organizer')
|
||||
redirect_uri = models.CharField(max_length=2500) # Only 255 in AbstractGrant, which caused problems
|
||||
|
||||
|
||||
class OAuthAccessToken(AbstractAccessToken):
|
||||
|
||||
@@ -30,11 +30,12 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
expires = serializers.DateTimeField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
seat = serializers.CharField(required=False, allow_null=True)
|
||||
sales_channel = serializers.CharField(required=False, default='sales_channel')
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'subevent', 'expires', 'includes_tax', 'answers', 'seat')
|
||||
'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel')
|
||||
|
||||
def create(self, validated_data):
|
||||
answers_data = validated_data.pop('answers')
|
||||
@@ -86,11 +87,12 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError('The specified seat ID is not unique.')
|
||||
else:
|
||||
validated_data['seat'] = seat
|
||||
if not seat.is_available():
|
||||
if not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web')):
|
||||
raise ValidationError(ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
|
||||
elif seated:
|
||||
raise ValidationError('The specified product requires to choose a seat.')
|
||||
|
||||
validated_data.pop('sales_channel')
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
|
||||
for answ_data in answers_data:
|
||||
|
||||
@@ -4,7 +4,8 @@ from django.db import transaction
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext as _
|
||||
from django_countries.serializers import CountryFieldMixin
|
||||
from rest_framework.fields import Field
|
||||
from pytz import common_timezones
|
||||
from rest_framework.fields import ChoiceField, Field
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
@@ -61,17 +62,27 @@ class PluginsField(Field):
|
||||
}
|
||||
|
||||
|
||||
class TimeZoneField(ChoiceField):
|
||||
def get_attribute(self, instance):
|
||||
return instance.cache.get_or_set(
|
||||
'timezone_name',
|
||||
lambda: instance.settings.timezone,
|
||||
3600
|
||||
)
|
||||
|
||||
|
||||
class EventSerializer(I18nAwareModelSerializer):
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
plugins = PluginsField(required=False, source='*')
|
||||
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
|
||||
timezone = TimeZoneField(required=False, choices=[(a, a) for a in common_timezones])
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
|
||||
'date_to', 'date_admission', 'is_public', 'presale_start',
|
||||
'presale_end', 'location', 'has_subevents', 'meta_data', 'seating_plan',
|
||||
'plugins', 'seat_category_mapping')
|
||||
'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan',
|
||||
'plugins', 'seat_category_mapping', 'timezone')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -156,8 +167,12 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
validated_data.pop('seat_category_mapping', None)
|
||||
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
|
||||
tz = validated_data.pop('timezone', None)
|
||||
event = super().create(validated_data)
|
||||
|
||||
if tz:
|
||||
event.settings.timezone = tz
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
for key, value in meta_data.items():
|
||||
@@ -182,8 +197,12 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
plugins = validated_data.pop('plugins', None)
|
||||
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
|
||||
tz = validated_data.pop('timezone', None)
|
||||
event = super().update(instance, validated_data)
|
||||
|
||||
if tz:
|
||||
event.settings.timezone = tz
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
current = {mv.property: mv for mv in event.meta_values.select_related('property')}
|
||||
@@ -239,6 +258,8 @@ class CloneEventSerializer(EventSerializer):
|
||||
plugins = validated_data.pop('plugins', None)
|
||||
is_public = validated_data.pop('is_public', None)
|
||||
testmode = validated_data.pop('testmode', None)
|
||||
has_subevents = validated_data.pop('has_subevents', None)
|
||||
tz = validated_data.pop('timezone', None)
|
||||
new_event = super().create(validated_data)
|
||||
|
||||
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
|
||||
@@ -250,7 +271,11 @@ class CloneEventSerializer(EventSerializer):
|
||||
new_event.is_public = is_public
|
||||
if testmode is not None:
|
||||
new_event.testmode = testmode
|
||||
if has_subevents is not None:
|
||||
new_event.has_subevents = has_subevents
|
||||
new_event.save()
|
||||
if tz:
|
||||
new_event.settings.timezone = tz
|
||||
|
||||
return new_event
|
||||
|
||||
@@ -277,8 +302,9 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
|
||||
'presale_start', 'presale_end', 'location', 'event', 'is_public', 'seating_plan',
|
||||
'item_price_overrides', 'variation_price_overrides', 'meta_data', 'seat_category_mapping')
|
||||
'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'event', 'is_public',
|
||||
'seating_plan', 'item_price_overrides', 'variation_price_overrides', 'meta_data',
|
||||
'seat_category_mapping')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
@@ -119,12 +119,9 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
||||
'show_quota_left', 'hidden_if_available', 'allow_waitinglist')
|
||||
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"has_variations": self.kwargs['has_variations']}
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data):
|
||||
@@ -134,6 +131,17 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
|
||||
Item.clean_available(data.get('available_from'), data.get('available_until'))
|
||||
|
||||
if data.get('issue_giftcard'):
|
||||
if data.get('tax_rule') and data.get('tax_rule').rate > 0:
|
||||
raise ValidationError(
|
||||
_("Gift card products should not be associated with non-zero tax rates since sales tax will be "
|
||||
"applied when the gift card is redeemed.")
|
||||
)
|
||||
if data.get('admission'):
|
||||
raise ValidationError(_(
|
||||
"Gift card products should not be admission products at the same time."
|
||||
))
|
||||
|
||||
return data
|
||||
|
||||
def validate_category(self, value):
|
||||
@@ -219,7 +227,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
model = Question
|
||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
|
||||
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||
'hidden', 'dependency_value', 'print_on_invoice')
|
||||
'hidden', 'dependency_value', 'print_on_invoice', 'help_text')
|
||||
|
||||
def validate_identifier(self, value):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
@@ -249,6 +257,9 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
|
||||
dep = full_data.get('dependency_question')
|
||||
if dep:
|
||||
if dep.ask_during_checkin:
|
||||
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
|
||||
|
||||
seen_ids = {self.instance.pk} if self.instance else set()
|
||||
while dep:
|
||||
if dep.pk in seen_ids:
|
||||
@@ -256,6 +267,9 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
seen_ids.add(dep.pk)
|
||||
dep = dep.dependency_question
|
||||
|
||||
if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
|
||||
raise ValidationError(_('This type of question cannot be asked during check-in.'))
|
||||
|
||||
Question.clean_items(event, full_data.get('items'))
|
||||
return data
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from collections import Counter
|
||||
from collections import Counter, defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
import pycountry
|
||||
@@ -14,10 +14,11 @@ 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.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
|
||||
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, Voucher,
|
||||
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
@@ -203,7 +204,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
model = OrderPosition
|
||||
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', 'seat')
|
||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -283,7 +284,7 @@ class OrderPaymentDateField(serializers.DateField):
|
||||
class OrderFeeSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = OrderFee
|
||||
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule')
|
||||
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
|
||||
|
||||
|
||||
class PaymentURLField(serializers.URLField):
|
||||
@@ -477,9 +478,13 @@ class AnswerCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
|
||||
class OrderFeeCreateSerializer(I18nAwareModelSerializer):
|
||||
_treat_value_as_percentage = serializers.BooleanField(default=False, required=False)
|
||||
_split_taxes_like_products = serializers.BooleanField(default=False, required=False)
|
||||
|
||||
class Meta:
|
||||
model = OrderFee
|
||||
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rule')
|
||||
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rule',
|
||||
'_treat_value_as_percentage', '_split_taxes_like_products')
|
||||
|
||||
def validate_tax_rule(self, tr):
|
||||
if tr and tr.event != self.context['event']:
|
||||
@@ -675,6 +680,20 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(errs)
|
||||
return data
|
||||
|
||||
def validate_testmode(self, testmode):
|
||||
if 'sales_channel' in self.initial_data:
|
||||
try:
|
||||
sales_channel = get_all_sales_channels()[self.initial_data['sales_channel']]
|
||||
|
||||
if testmode and not sales_channel.testmode_supported:
|
||||
raise ValidationError('This sales channel does not provide support for testmode.')
|
||||
except KeyError:
|
||||
# We do not need to raise a ValidationError here, since there is another check to validate the
|
||||
# sales_channel
|
||||
pass
|
||||
|
||||
return testmode
|
||||
|
||||
def create(self, validated_data):
|
||||
fees_data = validated_data.pop('fees') if 'fees' in validated_data else []
|
||||
positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
|
||||
@@ -701,6 +720,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
consume_carts = validated_data.pop('consume_carts', [])
|
||||
delete_cps = []
|
||||
quota_avail_cache = {}
|
||||
v_budget = {}
|
||||
voucher_usage = Counter()
|
||||
if consume_carts:
|
||||
for cp in CartPosition.objects.filter(
|
||||
@@ -723,9 +743,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
errs = [{} for p in positions_data]
|
||||
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
|
||||
if pos_data.get('voucher'):
|
||||
v = pos_data['voucher']
|
||||
|
||||
if pos_data.get('addon_to'):
|
||||
errs[i]['voucher'] = ['Vouchers are currently not supported for add-on products.']
|
||||
continue
|
||||
|
||||
if not v.applies_to(pos_data['item'], pos_data.get('variation')):
|
||||
errs[i]['voucher'] = [error_messages['voucher_invalid_item']]
|
||||
continue
|
||||
@@ -749,6 +774,44 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
'The voucher has already been used the maximum number of times.'
|
||||
]
|
||||
|
||||
if v.budget is not None:
|
||||
price = pos_data.get('price')
|
||||
if price is None:
|
||||
price = get_price(
|
||||
item=pos_data.get('item'),
|
||||
variation=pos_data.get('variation'),
|
||||
voucher=v,
|
||||
custom_price=None,
|
||||
subevent=pos_data.get('subevent'),
|
||||
addon_to=pos_data.get('addon_to'),
|
||||
invoice_address=ia,
|
||||
).gross
|
||||
pbv = get_price(
|
||||
item=pos_data['item'],
|
||||
variation=pos_data.get('variation'),
|
||||
voucher=None,
|
||||
custom_price=None,
|
||||
subevent=pos_data.get('subevent'),
|
||||
addon_to=pos_data.get('addon_to'),
|
||||
invoice_address=ia,
|
||||
)
|
||||
|
||||
if v not in v_budget:
|
||||
v_budget[v] = v.budget - v.budget_used()
|
||||
disc = pbv.gross - price
|
||||
if disc > v_budget[v]:
|
||||
new_disc = v_budget[v]
|
||||
v_budget[v] -= new_disc
|
||||
if new_disc == Decimal('0.00') or pos_data.get('price') is not None:
|
||||
errs[i]['voucher'] = [
|
||||
'The voucher has a remaining budget of {}, therefore a discount of {} can not be '
|
||||
'given.'.format(v_budget[v] + new_disc, disc)
|
||||
]
|
||||
continue
|
||||
pos_data['price'] = price + (disc - new_disc)
|
||||
else:
|
||||
v_budget[v] -= disc
|
||||
|
||||
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
|
||||
if pos_data.get('seat'):
|
||||
if not seated:
|
||||
@@ -759,7 +822,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
errs[i]['seat'] = ['The specified seat does not exist.']
|
||||
else:
|
||||
pos_data['seat'] = seat
|
||||
if (seat not in free_seats and not seat.is_available()) or seat in seats_seen:
|
||||
if (seat not in free_seats and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat in seats_seen:
|
||||
errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
|
||||
seats_seen.add(seat)
|
||||
elif seated:
|
||||
@@ -837,6 +900,17 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
pos.tax_rule = pos.item.tax_rule
|
||||
else:
|
||||
pos._calculate_tax()
|
||||
|
||||
pos.price_before_voucher = get_price(
|
||||
item=pos.item,
|
||||
variation=pos.variation,
|
||||
voucher=None,
|
||||
custom_price=None,
|
||||
subevent=pos.subevent,
|
||||
addon_to=pos.addon_to,
|
||||
invoice_address=ia,
|
||||
).gross
|
||||
|
||||
if pos.voucher:
|
||||
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
|
||||
pos.save()
|
||||
@@ -849,15 +923,53 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
for cp in delete_cps:
|
||||
cp.delete()
|
||||
|
||||
order.total = sum([p.price for p in order.positions.all()])
|
||||
for fee_data in fees_data:
|
||||
f = OrderFee(**fee_data)
|
||||
f.order = order
|
||||
f._calculate_tax()
|
||||
f.save()
|
||||
is_percentage = fee_data.pop('_treat_value_as_percentage', False)
|
||||
if is_percentage:
|
||||
fee_data['value'] = round_decimal(order.total * (fee_data['value'] / Decimal('100.00')),
|
||||
self.context['event'].currency)
|
||||
is_split_taxes = fee_data.pop('_split_taxes_like_products', False)
|
||||
|
||||
order.total = sum([p.price for p in order.positions.all()]) + sum([f.value for f in order.fees.all()])
|
||||
if is_split_taxes:
|
||||
d = defaultdict(lambda: Decimal('0.00'))
|
||||
trz = TaxRule.zero()
|
||||
for p in pos_map.values():
|
||||
tr = p.tax_rule
|
||||
d[tr] += p.price - p.tax_value
|
||||
|
||||
base_values = sorted([tuple(t) for t in d.items()], key=lambda t: (t[0] or trz).rate)
|
||||
sum_base = sum(t[1] for t in base_values)
|
||||
fee_values = [(t[0], round_decimal(fee_data['value'] * t[1] / sum_base, self.context['event'].currency))
|
||||
for t in base_values]
|
||||
sum_fee = sum(t[1] for t in fee_values)
|
||||
|
||||
# If there are rounding differences, we fix them up, but always leaning to the benefit of the tax
|
||||
# authorities
|
||||
if sum_fee > fee_data['value']:
|
||||
fee_values[0] = (fee_values[0][0], fee_values[0][1] + (fee_data['value'] - sum_fee))
|
||||
elif sum_fee < fee_data['value']:
|
||||
fee_values[-1] = (fee_values[-1][0], fee_values[-1][1] + (fee_data['value'] - sum_fee))
|
||||
|
||||
for tr, val in fee_values:
|
||||
fee_data['tax_rule'] = tr
|
||||
fee_data['value'] = val
|
||||
f = OrderFee(**fee_data)
|
||||
f.order = order
|
||||
f._calculate_tax()
|
||||
f.save()
|
||||
else:
|
||||
f = OrderFee(**fee_data)
|
||||
f.order = order
|
||||
f._calculate_tax()
|
||||
f.save()
|
||||
|
||||
order.total += sum([f.value for f in order.fees.all()])
|
||||
order.save(update_fields=['total'])
|
||||
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
|
||||
payment_provider = 'free'
|
||||
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
@@ -888,10 +1000,25 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
return order
|
||||
|
||||
|
||||
class LinePositionField(serializers.IntegerField):
|
||||
"""
|
||||
Internally, the position field is stored starting at 0, but for the API, starting at 1 makes it
|
||||
more consistent with other models
|
||||
"""
|
||||
|
||||
def to_representation(self, value):
|
||||
return super().to_representation(value) + 1
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return super().to_internal_value(data) - 1
|
||||
|
||||
|
||||
class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
||||
position = LinePositionField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = InvoiceLine
|
||||
fields = ('description', 'gross_value', 'tax_value', 'tax_rate', 'tax_name')
|
||||
fields = ('position', 'description', 'gross_value', 'tax_value', 'tax_rate', 'tax_name')
|
||||
|
||||
|
||||
class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import CompatibleJSONField
|
||||
from pretix.base.models import Organizer, SeatingPlan
|
||||
from pretix.base.models import GiftCard, Organizer, SeatingPlan
|
||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||
|
||||
|
||||
@@ -18,3 +25,27 @@ class SeatingPlanSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = SeatingPlan
|
||||
fields = ('id', 'name', 'layout')
|
||||
|
||||
|
||||
class GiftCardSerializer(I18nAwareModelSerializer):
|
||||
value = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal('0.00'))
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
s = data['secret']
|
||||
qs = GiftCard.objects.filter(
|
||||
secret=s
|
||||
).filter(
|
||||
Q(issuer=self.context["organizer"]) | Q(issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
|
||||
)
|
||||
if self.instance:
|
||||
qs = qs.exclude(pk=self.instance.pk)
|
||||
if qs.exists():
|
||||
raise ValidationError(
|
||||
{'secret': _('A gift card with the same secret already exists in your or an affiliated organizer account.')}
|
||||
)
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = GiftCard
|
||||
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode')
|
||||
|
||||
@@ -2,15 +2,23 @@ from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import Voucher
|
||||
from pretix.base.models import Seat, Voucher
|
||||
|
||||
|
||||
class VoucherListSerializer(serializers.ListSerializer):
|
||||
def create(self, validated_data):
|
||||
codes = set()
|
||||
seats = set()
|
||||
errs = []
|
||||
err = False
|
||||
for voucher_data in validated_data:
|
||||
if voucher_data.get('seat') and (voucher_data.get('seat'), voucher_data.get('subevent')) in seats:
|
||||
err = True
|
||||
errs.append({'code': ['Duplicate seat ID in request.']})
|
||||
continue
|
||||
else:
|
||||
seats.add((voucher_data.get('seat'), voucher_data.get('subevent')))
|
||||
|
||||
if voucher_data['code'] in codes:
|
||||
err = True
|
||||
errs.append({'code': ['Duplicate voucher code in request.']})
|
||||
@@ -22,12 +30,19 @@ class VoucherListSerializer(serializers.ListSerializer):
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class SeatGuidField(serializers.CharField):
|
||||
def to_representation(self, val: Seat):
|
||||
return val.seat_guid
|
||||
|
||||
|
||||
class VoucherSerializer(I18nAwareModelSerializer):
|
||||
seat = SeatGuidField(allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Voucher
|
||||
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
|
||||
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
|
||||
'tag', 'comment', 'subevent', 'show_hidden_items')
|
||||
'tag', 'comment', 'subevent', 'show_hidden_items', 'seat')
|
||||
read_only_fields = ('id', 'redeemed')
|
||||
list_serializer_class = VoucherListSerializer
|
||||
|
||||
@@ -39,7 +54,8 @@ class VoucherSerializer(I18nAwareModelSerializer):
|
||||
|
||||
Voucher.clean_item_properties(
|
||||
full_data, self.context.get('event'),
|
||||
full_data.get('quota'), full_data.get('item'), full_data.get('variation')
|
||||
full_data.get('quota'), full_data.get('item'), full_data.get('variation'),
|
||||
block_quota=full_data.get('block_quota')
|
||||
)
|
||||
Voucher.clean_subevent(
|
||||
full_data, self.context.get('event')
|
||||
@@ -61,4 +77,10 @@ class VoucherSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
Voucher.clean_voucher_code(full_data, self.context.get('event'), self.instance.pk if self.instance else None)
|
||||
|
||||
if full_data.get('seat'):
|
||||
data['seat'] = Voucher.clean_seat_id(
|
||||
full_data, full_data.get('item'), full_data.get('quota'), self.context.get('event'),
|
||||
self.instance.pk if self.instance else None
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@@ -19,6 +19,7 @@ orga_router.register(r'events', event.EventViewSet)
|
||||
orga_router.register(r'subevents', event.SubEventViewSet)
|
||||
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
|
||||
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
|
||||
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
|
||||
|
||||
event_router = routers.DefaultRouter()
|
||||
event_router.register(r'subevents', event.SubEventViewSet)
|
||||
|
||||
@@ -18,6 +18,7 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
from pretix.presale.views.organizer import filter_qs_by_attr
|
||||
|
||||
with scopes_disabled():
|
||||
class EventFilter(FilterSet):
|
||||
@@ -85,6 +86,8 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
|
||||
qs = filter_qs_by_attr(qs, self.request)
|
||||
|
||||
return qs.prefetch_related(
|
||||
'meta_values', 'meta_values__property', 'seat_category_mappings'
|
||||
)
|
||||
@@ -130,6 +133,7 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(organizer=self.request.organizer)
|
||||
serializer.instance.set_defaults()
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.added',
|
||||
user=self.request.user,
|
||||
@@ -241,6 +245,9 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
event__organizer=self.request.organizer,
|
||||
event__in=self.request.user.get_events_with_any_permission()
|
||||
)
|
||||
|
||||
qs = filter_qs_by_attr(qs, self.request)
|
||||
|
||||
return qs.prefetch_related(
|
||||
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings'
|
||||
)
|
||||
|
||||
@@ -62,7 +62,6 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['has_variations'] = self.request.data.get('has_variations')
|
||||
return ctx
|
||||
|
||||
def perform_update(self, serializer):
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytz
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Prefetch, Q
|
||||
from django.db.models.functions import Coalesce, Concat
|
||||
from django.http import FileResponse
|
||||
from django.http import FileResponse, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import ugettext as _
|
||||
@@ -30,8 +30,8 @@ from pretix.api.serializers.order import (
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
|
||||
Order, OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
|
||||
generate_position_secret, generate_secret,
|
||||
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota,
|
||||
TeamAPIToken, generate_position_secret, generate_secret,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.services import tickets
|
||||
@@ -48,7 +48,7 @@ from pretix.base.services.orders import (
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.tickets import generate
|
||||
from pretix.base.signals import (
|
||||
order_modified, order_placed, register_ticket_outputs,
|
||||
order_modified, order_paid, order_placed, register_ticket_outputs,
|
||||
)
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
|
||||
@@ -82,20 +82,29 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
|
||||
fqs = OrderFee.all
|
||||
else:
|
||||
fqs = OrderFee.objects
|
||||
qs = self.request.event.orders.prefetch_related(
|
||||
'fees', 'payments', 'refunds', 'refunds__payment'
|
||||
Prefetch('fees', queryset=fqs.all()),
|
||||
'payments', 'refunds', 'refunds__payment'
|
||||
).select_related(
|
||||
'invoice_address'
|
||||
)
|
||||
|
||||
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
|
||||
opq = OrderPosition.all
|
||||
else:
|
||||
opq = OrderPosition.objects
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.all().prefetch_related(
|
||||
opq.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'item__category', 'addon_to', 'seat',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation', 'seat'))
|
||||
Prefetch('addons', opq.select_related('item', 'variation', 'seat'))
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -103,7 +112,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.all().prefetch_related(
|
||||
opq.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
|
||||
)
|
||||
)
|
||||
@@ -148,12 +157,16 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
generate.apply_async(args=('order', order.pk, provider.identifier))
|
||||
raise RetryException()
|
||||
else:
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), order.code,
|
||||
provider.identifier, ct.extension
|
||||
)
|
||||
return resp
|
||||
if ct.type == 'text/uri-list':
|
||||
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
|
||||
return resp
|
||||
else:
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), order.code,
|
||||
provider.identifier, ct.extension
|
||||
)
|
||||
return resp
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def mark_paid(self, request, **kwargs):
|
||||
@@ -169,9 +182,26 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
amount=ps
|
||||
)
|
||||
except OrderPayment.DoesNotExist:
|
||||
order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING,
|
||||
OrderPayment.PAYMENT_STATE_CREATED)) \
|
||||
.update(state=OrderPayment.PAYMENT_STATE_CANCELED)
|
||||
for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING,
|
||||
OrderPayment.PAYMENT_STATE_CREATED)):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
p.payment_provider.cancel_payment(p)
|
||||
order.log_action('pretix.event.order.payment.canceled', {
|
||||
'local_id': p.local_id,
|
||||
'provider': p.provider,
|
||||
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
except PaymentException as e:
|
||||
order.log_action(
|
||||
'pretix.event.order.payment.canceled.failed',
|
||||
{
|
||||
'local_id': p.local_id,
|
||||
'provider': p.provider,
|
||||
'error': str(e)
|
||||
},
|
||||
user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth
|
||||
)
|
||||
p = order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
provider='manual',
|
||||
@@ -441,48 +471,52 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth,
|
||||
)
|
||||
order_placed.send(self.request.event, order=order)
|
||||
|
||||
gen_invoice = invoice_qualified(order) and (
|
||||
(order.event.settings.get('invoice_generate') == 'True') or
|
||||
(order.event.settings.get('invoice_generate') == 'paid' and order.status == Order.STATUS_PAID)
|
||||
) and not order.invoices.last()
|
||||
invoice = None
|
||||
if gen_invoice:
|
||||
invoice = generate_invoice(order, trigger_pdf=True)
|
||||
with language(order.locale):
|
||||
order_placed.send(self.request.event, order=order)
|
||||
if order.status == Order.STATUS_PAID:
|
||||
order_paid.send(self.request.event, order=order)
|
||||
|
||||
if send_mail:
|
||||
payment = order.payments.last()
|
||||
free_flow = (
|
||||
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
|
||||
not order.require_approval and payment.provider == "free"
|
||||
)
|
||||
if free_flow:
|
||||
email_template = request.event.settings.mail_text_order_free
|
||||
log_entry = 'pretix.event.order.email.order_free'
|
||||
email_attendees = request.event.settings.mail_send_order_free_attendee
|
||||
email_attendees_template = request.event.settings.mail_text_order_free_attendee
|
||||
else:
|
||||
email_template = request.event.settings.mail_text_order_placed
|
||||
log_entry = 'pretix.event.order.email.order_placed'
|
||||
email_attendees = request.event.settings.mail_send_order_placed_attendee
|
||||
email_attendees_template = request.event.settings.mail_text_order_placed_attendee
|
||||
gen_invoice = invoice_qualified(order) and (
|
||||
(order.event.settings.get('invoice_generate') == 'True') or
|
||||
(order.event.settings.get('invoice_generate') == 'paid' and order.status == Order.STATUS_PAID)
|
||||
) and not order.invoices.last()
|
||||
invoice = None
|
||||
if gen_invoice:
|
||||
invoice = generate_invoice(order, trigger_pdf=True)
|
||||
|
||||
_order_placed_email(
|
||||
request.event, order, payment.payment_provider if payment else None, email_template,
|
||||
log_entry, invoice, payment
|
||||
)
|
||||
if email_attendees:
|
||||
for p in order.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry)
|
||||
if send_mail:
|
||||
payment = order.payments.last()
|
||||
free_flow = (
|
||||
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
|
||||
not order.require_approval and payment.provider == "free"
|
||||
)
|
||||
if free_flow:
|
||||
email_template = request.event.settings.mail_text_order_free
|
||||
log_entry = 'pretix.event.order.email.order_free'
|
||||
email_attendees = request.event.settings.mail_send_order_free_attendee
|
||||
email_attendees_template = request.event.settings.mail_text_order_free_attendee
|
||||
else:
|
||||
email_template = request.event.settings.mail_text_order_placed
|
||||
log_entry = 'pretix.event.order.email.order_placed'
|
||||
email_attendees = request.event.settings.mail_send_order_placed_attendee
|
||||
email_attendees_template = request.event.settings.mail_text_order_placed_attendee
|
||||
|
||||
if not free_flow and order.status == Order.STATUS_PAID and payment:
|
||||
payment._send_paid_mail(invoice, None, '')
|
||||
if self.request.event.settings.mail_send_order_paid_attendee:
|
||||
_order_placed_email(
|
||||
request.event, order, payment.payment_provider if payment else None, email_template,
|
||||
log_entry, invoice, payment
|
||||
)
|
||||
if email_attendees:
|
||||
for p in order.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
payment._send_paid_mail_attendee(p, None)
|
||||
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry)
|
||||
|
||||
if not free_flow and order.status == Order.STATUS_PAID and payment:
|
||||
payment._send_paid_mail(invoice, None, '')
|
||||
if self.request.event.settings.mail_send_order_paid_attendee:
|
||||
for p in order.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
payment._send_paid_mail_attendee(p, None)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
@@ -629,11 +663,16 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
qs = OrderPosition.objects.filter(order__event=self.request.event)
|
||||
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
|
||||
qs = OrderPosition.all
|
||||
else:
|
||||
qs = OrderPosition.objects
|
||||
|
||||
qs = qs.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('addons', qs.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
@@ -641,7 +680,7 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
),
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
qs.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
@@ -651,7 +690,7 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question'
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer', 'seat'
|
||||
)
|
||||
@@ -759,12 +798,16 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
generate.apply_async(args=('orderposition', pos.pk, provider.identifier))
|
||||
raise RetryException()
|
||||
else:
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), pos.order.code, pos.positionid,
|
||||
provider.identifier, ct.extension
|
||||
)
|
||||
return resp
|
||||
if ct.type == 'text/uri-list':
|
||||
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
|
||||
return resp
|
||||
else:
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), pos.order.code, pos.positionid,
|
||||
provider.identifier, ct.extension
|
||||
)
|
||||
return resp
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
try:
|
||||
@@ -886,13 +929,16 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
|
||||
return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
with transaction.atomic():
|
||||
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
|
||||
payment.save()
|
||||
payment.order.log_action('pretix.event.order.payment.canceled', {
|
||||
'local_id': payment.local_id,
|
||||
'provider': payment.provider,
|
||||
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
payment.payment_provider.cancel_payment(payment)
|
||||
payment.order.log_action('pretix.event.order.payment.canceled', {
|
||||
'local_id': payment.local_id,
|
||||
'provider': payment.provider,
|
||||
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
except PaymentException as e:
|
||||
return Response({'detail': 'External error: {}'.format(str(e))},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
from rest_framework import filters, viewsets
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from rest_framework import filters, serializers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.serializers.organizer import (
|
||||
OrganizerSerializer, SeatingPlanSerializer,
|
||||
GiftCardSerializer, OrganizerSerializer, SeatingPlanSerializer,
|
||||
)
|
||||
from pretix.base.models import Organizer, SeatingPlan
|
||||
from pretix.base.models import GiftCard, Organizer, SeatingPlan
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
|
||||
@@ -81,3 +86,70 @@ class SeatingPlanViewSet(viewsets.ModelViewSet):
|
||||
data={'id': instance.pk}
|
||||
)
|
||||
instance.delete()
|
||||
|
||||
|
||||
class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = GiftCardSerializer
|
||||
queryset = GiftCard.objects.none()
|
||||
permission = 'can_manage_gift_cards'
|
||||
write_permission = 'can_manage_gift_cards'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.issued_gift_cards.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
value = serializer.validated_data.pop('value')
|
||||
inst = serializer.save(issuer=self.request.organizer)
|
||||
inst.transactions.create(value=value)
|
||||
inst.log_action(
|
||||
'pretix.giftcards.transaction.manual',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
GiftCard.objects.select_for_update().get(pk=self.get_object().pk)
|
||||
old_value = serializer.instance.value
|
||||
value = serializer.validated_data.pop('value')
|
||||
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
|
||||
testmode=serializer.instance.testmode)
|
||||
diff = value - old_value
|
||||
inst.transactions.create(value=diff)
|
||||
inst.log_action(
|
||||
'pretix.giftcards.transaction.manual',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'value': diff}
|
||||
)
|
||||
return inst
|
||||
|
||||
@action(detail=True, methods=["POST"])
|
||||
@transaction.atomic()
|
||||
def transact(self, request, **kwargs):
|
||||
gc = GiftCard.objects.select_for_update().get(pk=self.get_object().pk)
|
||||
value = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
||||
request.data.get('value')
|
||||
)
|
||||
if gc.value + value < Decimal('0.00'):
|
||||
return Response({
|
||||
'value': ['The gift card does not have sufficient credit for this operation.']
|
||||
}, status=status.HTTP_409_CONFLICT)
|
||||
gc.transactions.create(value=value)
|
||||
gc.log_action(
|
||||
'pretix.giftcards.transaction.manual',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'value': value}
|
||||
)
|
||||
return Response(GiftCardSerializer(gc).data, status=status.HTTP_200_OK)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
raise MethodNotAllowed("Gift cards cannot be deleted.")
|
||||
|
||||
@@ -45,7 +45,7 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
write_permission = 'can_change_vouchers'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.vouchers.all()
|
||||
return self.request.event.vouchers.select_related('seat').all()
|
||||
|
||||
def _predict_quota_check(self, data, instance):
|
||||
# This method predicts if Voucher.clean_quota_needs_checking
|
||||
|
||||
@@ -13,7 +13,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import invoice # NOQA
|
||||
from . import notifications # NOQA
|
||||
from . import email # NOQA
|
||||
from .services import auth, checkin, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA
|
||||
from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
|
||||
try:
|
||||
from .celery_app import app as celery_app # NOQA
|
||||
|
||||
109
src/pretix/base/auth.py
Normal file
109
src/pretix/base/auth.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from collections import OrderedDict
|
||||
from importlib import import_module
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def get_auth_backends():
|
||||
backends = {}
|
||||
for b in settings.PRETIX_AUTH_BACKENDS:
|
||||
mod, name = b.rsplit('.', 1)
|
||||
b = getattr(import_module(mod), name)()
|
||||
backends[b.identifier] = b
|
||||
return backends
|
||||
|
||||
|
||||
class BaseAuthBackend:
|
||||
"""
|
||||
This base class defines the interface that needs to be implemented by every class that supplies
|
||||
an authentication method to pretix. Please note that pretix authentication backends are different
|
||||
from plain Django authentication backends! Be sure to read the documentation chapter on authentication
|
||||
backends before you implement one.
|
||||
"""
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
A short and unique identifier for this authentication backend.
|
||||
This should only contain lowercase letters and in most cases will
|
||||
be the same as your package name.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
"""
|
||||
A human-readable name of this authentication backend.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def visible(self):
|
||||
"""
|
||||
Whether or not this backend can be selected by users actively. Set this to ``False``
|
||||
if you only implement ``request_authenticate``.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def login_form_fields(self) -> dict:
|
||||
"""
|
||||
This property may return form fields that the user needs to fill in to log in.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def form_authenticate(self, request, form_data):
|
||||
"""
|
||||
This method will be called after the user filled in the login form. ``request`` will contain
|
||||
the current request and ``form_data`` the input for the form fields defined in ``login_form_fields``.
|
||||
You are expected to either return a ``User`` object (if login was successful) or ``None``.
|
||||
"""
|
||||
return
|
||||
|
||||
def request_authenticate(self, request):
|
||||
"""
|
||||
This method will be called when the user opens the login form. If the user already has a valid session
|
||||
according to your login mechanism, for example a cookie set by a different system or HTTP header set by a
|
||||
reverse proxy, you can directly return a ``User`` object that will be logged in.
|
||||
|
||||
``request`` will contain the current request.
|
||||
You are expected to either return a ``User`` object (if login was successful) or ``None``.
|
||||
"""
|
||||
return
|
||||
|
||||
def authentication_url(self, request):
|
||||
"""
|
||||
This method will be called to populate the URL for your authentication method's tab on the login page.
|
||||
For example, if your method works through OAuth, you could return the URL of the OAuth authorization URL the
|
||||
user needs to visit.
|
||||
|
||||
If you return ``None`` (the default), the link will point to a page that shows the form defined by
|
||||
``login_form_fields``.
|
||||
"""
|
||||
return
|
||||
|
||||
|
||||
class NativeAuthBackend(BaseAuthBackend):
|
||||
identifier = 'native'
|
||||
verbose_name = _('pretix User')
|
||||
|
||||
@property
|
||||
def login_form_fields(self) -> dict:
|
||||
"""
|
||||
This property may return form fields that the user needs to fill in
|
||||
to log in.
|
||||
"""
|
||||
d = OrderedDict([
|
||||
('email', forms.EmailField(label=_("E-mail"), max_length=254,
|
||||
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
|
||||
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput)),
|
||||
])
|
||||
return d
|
||||
|
||||
def form_authenticate(self, request, form_data):
|
||||
u = authenticate(request=request, email=form_data['email'].lower(), password=form_data['password'])
|
||||
if u and u.auth_backend == self.identifier:
|
||||
return u
|
||||
@@ -35,6 +35,32 @@ class SalesChannel:
|
||||
"""
|
||||
return "circle"
|
||||
|
||||
@property
|
||||
def testmode_supported(self) -> bool:
|
||||
"""
|
||||
Indication, if a saleschannels supports test mode orders
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def payment_restrictions_supported(self) -> bool:
|
||||
"""
|
||||
If this property is ``True``, organizers can restrict the usage of payment providers to this sales channel.
|
||||
|
||||
Example: pretixPOS provides its own sales channel, ignores the configured payment providers completely and
|
||||
handles payments locally. Therefor, this property should be set to ``False`` for the pretixPOS sales channel as
|
||||
the event organizer cannot restrict the usage of any payment provider through the backend.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def unlimited_items_per_order(self) -> bool:
|
||||
"""
|
||||
If this property is ``True``, purchases made using this sales channel are not limited to the maximum amount of
|
||||
items defined in the event settings.
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
def get_all_sales_channels():
|
||||
global _ALL_CHANNELS
|
||||
|
||||
@@ -4,7 +4,9 @@ from django.conf import settings
|
||||
|
||||
|
||||
def contextprocessor(request):
|
||||
ctx = {}
|
||||
ctx = {
|
||||
'rtl': getattr(request, 'LANGUAGE_CODE', 'en') in settings.LANGUAGES_RTL,
|
||||
}
|
||||
if settings.DEBUG and 'runserver' not in sys.argv:
|
||||
ctx['debug_warning'] = True
|
||||
elif 'runserver' in sys.argv:
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.core.mail.backends.smtp import EmailBackend
|
||||
from django.dispatch import receiver
|
||||
from django.template.loader import get_template
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import get_language, ugettext_lazy as _
|
||||
from inlinestyler.utils import inline_css
|
||||
|
||||
from pretix.base.i18n import LazyCurrencyNumber, LazyDate, LazyNumber
|
||||
@@ -112,7 +112,8 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
'site_url': settings.SITE_URL,
|
||||
'body': body_md,
|
||||
'subject': str(subject),
|
||||
'color': '#8E44B3'
|
||||
'color': '#8E44B3',
|
||||
'rtl': get_language() in settings.LANGUAGES_RTL
|
||||
}
|
||||
if self.event:
|
||||
htmlctx['event'] = self.event
|
||||
@@ -259,6 +260,9 @@ def base_placeholders(sender, **kwargs):
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event', ['event'], lambda event: event.name, lambda event: event.name
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'code', ['order'], lambda order: order.code, 'F8VVL'
|
||||
),
|
||||
@@ -374,6 +378,23 @@ def base_placeholders(sender, **kwargs):
|
||||
'code', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.voucher.code,
|
||||
'68CYU2H6ZTP3WLK5'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'voucher_list', ['voucher_list'], lambda voucher_list: '\n'.join(voucher_list),
|
||||
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
}), lambda event: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
})
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'name', ['name'], lambda name: name,
|
||||
_('John Doe')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'comment', ['comment'], lambda comment: comment,
|
||||
_('An individual text with a reason can be inserted here.'),
|
||||
|
||||
@@ -128,7 +128,7 @@ class ListExporter(BaseExporter):
|
||||
|
||||
def _render_xlsx(self, form_data, output_file=None):
|
||||
wb = Workbook()
|
||||
ws = wb.get_active_sheet()
|
||||
ws = wb.active
|
||||
try:
|
||||
ws.title = str(self.verbose_name)
|
||||
except:
|
||||
@@ -207,7 +207,7 @@ class MultiSheetListExporter(ListExporter):
|
||||
|
||||
def _render_xlsx(self, form_data, output_file=None):
|
||||
wb = Workbook()
|
||||
ws = wb.get_active_sheet()
|
||||
ws = wb.active
|
||||
wb.remove(ws)
|
||||
for s, l in self.sheets:
|
||||
ws = wb.create_sheet(str(l))
|
||||
|
||||
@@ -278,6 +278,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
]
|
||||
if self.event.has_subevents:
|
||||
headers.append(pgettext('subevent', 'Date'))
|
||||
headers.append(_('Start date'))
|
||||
headers.append(_('End date'))
|
||||
headers += [
|
||||
_('Product'),
|
||||
_('Variation'),
|
||||
@@ -323,7 +325,12 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
]
|
||||
if self.event.has_subevents:
|
||||
row.append(op.subevent)
|
||||
row.append(op.subevent.name)
|
||||
row.append(op.subevent.date_from.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
|
||||
if op.subevent.date_to:
|
||||
row.append(op.subevent.date_to.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
|
||||
else:
|
||||
row.append('')
|
||||
row += [
|
||||
str(op.item),
|
||||
str(op.variation) if op.variation else '',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
)
|
||||
@@ -14,32 +13,34 @@ class LoginForm(forms.Form):
|
||||
Base class for authenticating users. Extend this to get a form that accepts
|
||||
username/password logins.
|
||||
"""
|
||||
email = forms.EmailField(label=_("E-mail"), max_length=254, widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))
|
||||
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
|
||||
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
||||
|
||||
error_messages = {
|
||||
'invalid_login': _("Please enter a correct email address and password."),
|
||||
'invalid_login': _("This combination of credentials is not known to our system."),
|
||||
'inactive': _("This account is inactive.")
|
||||
}
|
||||
|
||||
def __init__(self, request=None, *args, **kwargs):
|
||||
def __init__(self, backend, request=None, *args, **kwargs):
|
||||
"""
|
||||
The 'request' parameter is set for custom auth use by subclasses.
|
||||
The form data comes in via the standard 'data' kwarg.
|
||||
"""
|
||||
self.request = request
|
||||
self.user_cache = None
|
||||
self.backend = backend
|
||||
super().__init__(*args, **kwargs)
|
||||
if not settings.PRETIX_LONG_SESSIONS:
|
||||
for k, f in backend.login_form_fields.items():
|
||||
self.fields[k] = f
|
||||
|
||||
# Authentication backends which use urls cannot have long sessions.
|
||||
if not settings.PRETIX_LONG_SESSIONS or backend.url:
|
||||
del self.fields['keep_logged_in']
|
||||
else:
|
||||
self.fields.move_to_end('keep_logged_in')
|
||||
|
||||
def clean(self):
|
||||
email = self.cleaned_data.get('email')
|
||||
password = self.cleaned_data.get('password')
|
||||
|
||||
if email and password:
|
||||
self.user_cache = authenticate(request=self.request, email=email.lower(), password=password)
|
||||
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
|
||||
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
|
||||
if self.user_cache is None:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['invalid_login'],
|
||||
@@ -181,3 +182,45 @@ class PasswordForgotForm(forms.Form):
|
||||
|
||||
def clean_email(self):
|
||||
return self.cleaned_data['email']
|
||||
|
||||
|
||||
class ReauthForm(forms.Form):
|
||||
error_messages = {
|
||||
'invalid_login': _("This combination of credentials is not known to our system."),
|
||||
'inactive': _("This account is inactive.")
|
||||
}
|
||||
|
||||
def __init__(self, backend, user, request=None, *args, **kwargs):
|
||||
"""
|
||||
The 'request' parameter is set for custom auth use by subclasses.
|
||||
The form data comes in via the standard 'data' kwarg.
|
||||
"""
|
||||
self.request = request
|
||||
self.user = user
|
||||
self.backend = backend
|
||||
self.backend.url = backend.authentication_url(self.request)
|
||||
super().__init__(*args, **kwargs)
|
||||
for k, f in backend.login_form_fields.items():
|
||||
self.fields[k] = f
|
||||
if 'email' in self.fields:
|
||||
self.fields['email'].disabled = True
|
||||
|
||||
def clean(self):
|
||||
self.cleaned_data['email'] = self.user.email
|
||||
user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
|
||||
if user_cache != self.user:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['invalid_login'],
|
||||
code='invalid_login'
|
||||
)
|
||||
else:
|
||||
self.confirm_login_allowed(user_cache)
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
def confirm_login_allowed(self, user: User):
|
||||
if not user.is_active:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['inactive'],
|
||||
code='inactive',
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import pycountry
|
||||
import pytz
|
||||
import vat_moss.errors
|
||||
import vat_moss.id
|
||||
from babel import localedata
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -21,11 +22,17 @@ from django.utils.translation import (
|
||||
)
|
||||
from django_countries import countries
|
||||
from django_countries.fields import Country, CountryField
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
from phonenumber_field.phonenumber import PhoneNumber
|
||||
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
||||
from phonenumbers import NumberParseException
|
||||
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
|
||||
|
||||
from pretix.base.forms.widgets import (
|
||||
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
|
||||
TimePickerWidget, UploadedFileWidget,
|
||||
)
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import InvoiceAddress, Question, QuestionOption
|
||||
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
|
||||
from pretix.base.settings import (
|
||||
@@ -41,6 +48,9 @@ from pretix.presale.signals import question_form_fields
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
REQUIRED_NAME_PARTS = ['given_name', 'family_name', 'full_name']
|
||||
|
||||
|
||||
class NamePartsWidget(forms.MultiWidget):
|
||||
widget = forms.TextInput
|
||||
autofill_map = {
|
||||
@@ -91,15 +101,21 @@ class NamePartsWidget(forms.MultiWidget):
|
||||
except (IndexError, TypeError):
|
||||
widget_value = None
|
||||
if id_:
|
||||
final_attrs = dict(
|
||||
these_attrs = dict(
|
||||
final_attrs,
|
||||
id='%s_%s' % (id_, i),
|
||||
title=self.scheme['fields'][i][1],
|
||||
placeholder=self.scheme['fields'][i][1],
|
||||
)
|
||||
final_attrs['autocomplete'] = (self.attrs.get('autocomplete', '') + ' ' + self.autofill_map.get(self.scheme['fields'][i][0], 'off')).strip()
|
||||
final_attrs['data-size'] = self.scheme['fields'][i][2]
|
||||
output.append(widget.render(name + '_%s' % i, widget_value, final_attrs, renderer=renderer))
|
||||
if self.scheme['fields'][i][0] in REQUIRED_NAME_PARTS:
|
||||
if self.field.required:
|
||||
these_attrs['required'] = 'required'
|
||||
these_attrs.pop('data-no-required-attr', None)
|
||||
these_attrs['autocomplete'] = (self.attrs.get('autocomplete', '') + ' ' + self.autofill_map.get(self.scheme['fields'][i][0], 'off')).strip()
|
||||
these_attrs['data-size'] = self.scheme['fields'][i][2]
|
||||
else:
|
||||
these_attrs = final_attrs
|
||||
output.append(widget.render(name + '_%s' % i, widget_value, these_attrs, renderer=renderer))
|
||||
return mark_safe(self.format_output(output))
|
||||
|
||||
def format_output(self, rendered_widgets) -> str:
|
||||
@@ -159,13 +175,45 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
|
||||
def clean(self, value) -> dict:
|
||||
value = super().clean(value)
|
||||
if self.one_required and (not value or not any(v for v in value)):
|
||||
if self.one_required and (not value or not any(v for v in value.values())):
|
||||
raise forms.ValidationError(self.error_messages['required'], code='required')
|
||||
if self.one_required:
|
||||
for k, v in value.items():
|
||||
if k in REQUIRED_NAME_PARTS and not v:
|
||||
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 WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
output = super().render(name, value, attrs, 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)
|
||||
|
||||
|
||||
def guess_country(event):
|
||||
# Try to guess the initial country from either the country of the merchant
|
||||
# or the locale. This will hopefully save at least some users some scrolling :)
|
||||
locale = get_language()
|
||||
country = event.settings.invoice_address_from_country
|
||||
if not country:
|
||||
valid_countries = countries.countries
|
||||
if '-' in locale:
|
||||
parts = locale.split('-')
|
||||
if parts[1].upper() in valid_countries:
|
||||
country = Country(parts[1].upper())
|
||||
elif parts[0].upper() in valid_countries:
|
||||
country = Country(parts[0].upper())
|
||||
else:
|
||||
if locale in valid_countries:
|
||||
country = Country(locale.upper())
|
||||
return country
|
||||
|
||||
|
||||
class BaseQuestionsForm(forms.Form):
|
||||
"""
|
||||
This form class is responsible for asking order-related questions. This includes
|
||||
@@ -315,6 +363,32 @@ class BaseQuestionsForm(forms.Form):
|
||||
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')),
|
||||
)
|
||||
elif q.type == Question.TYPE_PHONENUMBER:
|
||||
babel_locale = 'en'
|
||||
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
|
||||
if localedata.exists(get_language()):
|
||||
babel_locale = get_language()
|
||||
elif localedata.exists(get_language()[:2]):
|
||||
babel_locale = get_language()[:2]
|
||||
with language(babel_locale):
|
||||
default_country = guess_country(event)
|
||||
default_prefix = None
|
||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
if str(default_country) in values:
|
||||
default_prefix = prefix
|
||||
try:
|
||||
initial = PhoneNumber().from_string(initial.answer) if initial else "+{}.".format(default_prefix)
|
||||
except NumberParseException:
|
||||
initial = None
|
||||
field = PhoneNumberField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
|
||||
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
|
||||
# the future.
|
||||
initial=initial,
|
||||
widget=WrappedPhoneNumberPrefixWidget()
|
||||
)
|
||||
field.question = q
|
||||
if answers:
|
||||
# Cache the answer object for later use
|
||||
@@ -348,6 +422,8 @@ class BaseQuestionsForm(forms.Form):
|
||||
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
|
||||
|
||||
def question_is_visible(parentid, qvals):
|
||||
if parentid not in question_cache:
|
||||
return False
|
||||
parentq = question_cache[parentid]
|
||||
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
|
||||
return False
|
||||
@@ -418,23 +494,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
|
||||
kwargs.setdefault('initial', {})
|
||||
if not kwargs.get('instance') or not kwargs['instance'].country:
|
||||
# Try to guess the initial country from either the country of the merchant
|
||||
# or the locale. This will hopefully save at least some users some scrolling :)
|
||||
locale = get_language()
|
||||
country = event.settings.invoice_address_from_country
|
||||
if not country:
|
||||
valid_countries = countries.countries
|
||||
if '-' in locale:
|
||||
parts = locale.split('-')
|
||||
if parts[1].upper() in valid_countries:
|
||||
country = Country(parts[1].upper())
|
||||
elif parts[0].upper() in valid_countries:
|
||||
country = Country(parts[0].upper())
|
||||
else:
|
||||
if locale in valid_countries:
|
||||
country = Country(locale.upper())
|
||||
|
||||
kwargs['initial']['country'] = country
|
||||
kwargs['initial']['country'] = guess_country(self.event)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
if not event.settings.invoice_address_vatid:
|
||||
@@ -493,7 +553,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
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'
|
||||
if not event.settings.invoice_name_required:
|
||||
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'
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@ class UserSettingsForm(forms.ModelForm):
|
||||
self.user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['email'].required = True
|
||||
if self.user.auth_backend != 'native':
|
||||
del self.fields['old_pw']
|
||||
del self.fields['new_pw']
|
||||
del self.fields['new_pw_repeat']
|
||||
self.fields['email'].disabled = True
|
||||
|
||||
def clean_old_pw(self):
|
||||
old_pw = self.cleaned_data.get('old_pw')
|
||||
|
||||
@@ -46,6 +46,14 @@ class TimePickerWidget(forms.TimeInput):
|
||||
|
||||
class UploadedFileWidget(forms.ClearableFileInput):
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Browsers can't recognize that the server already has a file uploaded
|
||||
# Don't mark this input as being required if we already have an answer
|
||||
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
|
||||
attrs = kwargs.get('attrs', {})
|
||||
if kwargs.get('required') and kwargs.get('initial'):
|
||||
attrs.update({'required': None})
|
||||
kwargs.update({'attrs': attrs})
|
||||
|
||||
self.position = kwargs.pop('position')
|
||||
self.event = kwargs.pop('event')
|
||||
self.answer = kwargs.pop('answer')
|
||||
|
||||
@@ -457,19 +457,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
id='normal')
|
||||
]
|
||||
|
||||
def _get_story(self, doc):
|
||||
has_taxes = any(il.tax_value for il in self.invoice.lines.all())
|
||||
|
||||
story = [
|
||||
NextPageTemplate('FirstPage'),
|
||||
Paragraph(pgettext('invoice', 'Invoice')
|
||||
if not self.invoice.is_cancellation
|
||||
else pgettext('invoice', 'Cancellation'),
|
||||
self.stylesheet['Heading1']),
|
||||
Spacer(1, 5 * mm),
|
||||
NextPageTemplate('OtherPages'),
|
||||
]
|
||||
|
||||
def _get_intro(self):
|
||||
story = []
|
||||
if self.invoice.internal_reference:
|
||||
story.append(Paragraph(
|
||||
pgettext('invoice', 'Customer reference: {reference}').format(reference=self.invoice.internal_reference),
|
||||
@@ -478,7 +467,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
if self.invoice.invoice_to_vat_id:
|
||||
story.append(Paragraph(
|
||||
pgettext('invoice', 'Customer VAT ID') + ':<br />' +
|
||||
pgettext('invoice', 'Customer VAT ID') + ': ' +
|
||||
bleach.clean(self.invoice.invoice_to_vat_id, tags=[]).replace("\n", "<br />\n"),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
@@ -494,6 +483,25 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal']))
|
||||
story.append(Spacer(1, 10 * mm))
|
||||
|
||||
return story
|
||||
|
||||
def _get_story(self, doc):
|
||||
has_taxes = any(il.tax_value for il in self.invoice.lines.all())
|
||||
|
||||
story = [
|
||||
NextPageTemplate('FirstPage'),
|
||||
Paragraph(
|
||||
(
|
||||
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
|
||||
else pgettext('invoice', 'Invoice')
|
||||
) if not self.invoice.is_cancellation else pgettext('invoice', 'Cancellation'),
|
||||
self.stylesheet['Heading1']
|
||||
),
|
||||
Spacer(1, 5 * mm),
|
||||
NextPageTemplate('OtherPages'),
|
||||
]
|
||||
story += self._get_intro()
|
||||
|
||||
taxvalue_map = defaultdict(Decimal)
|
||||
grossvalue_map = defaultdict(Decimal)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
|
||||
IGNORED_ATTRS = [
|
||||
# (field type, attribute name, blacklist of field sub-types)
|
||||
# (field type, attribute name, banlist of field sub-types)
|
||||
(models.Field, 'verbose_name', []),
|
||||
(models.Field, 'help_text', []),
|
||||
(models.Field, 'validators', []),
|
||||
@@ -38,8 +38,8 @@ original_deconstruct = models.Field.deconstruct
|
||||
|
||||
def new_deconstruct(self):
|
||||
name, path, args, kwargs = original_deconstruct(self)
|
||||
for ftype, attr, blacklist in IGNORED_ATTRS:
|
||||
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in blacklist):
|
||||
for ftype, attr, banlist in IGNORED_ATTRS:
|
||||
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in banlist):
|
||||
kwargs.pop(attr, None)
|
||||
return name, path, args, kwargs
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ from django.core.management.commands.migrate import Command as Parent
|
||||
|
||||
|
||||
class OutputFilter(OutputWrapper):
|
||||
blacklist = (
|
||||
banlist = (
|
||||
"Your models have changes that are not yet reflected",
|
||||
"Run 'manage.py makemigrations' to make new "
|
||||
)
|
||||
|
||||
def write(self, msg, style_func=None, ending=None):
|
||||
if any(b in msg for b in self.blacklist):
|
||||
if any(b in msg for b in self.banlist):
|
||||
return
|
||||
super().write(msg, style_func, ending)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from django.utils.translation.trans_real import (
|
||||
parse_accept_lang_header,
|
||||
)
|
||||
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.multidomain.urlreverse import get_domain
|
||||
|
||||
_supported = None
|
||||
@@ -187,6 +188,11 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
# https://github.com/pretix/pretix/issues/765
|
||||
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
|
||||
|
||||
img_src = []
|
||||
gs = GlobalSettingsObject()
|
||||
if gs.settings.leaflet_tiles:
|
||||
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
|
||||
|
||||
h = {
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
@@ -196,7 +202,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'child-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}", "https://checkout.stripe.com"],
|
||||
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"],
|
||||
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"] + img_src,
|
||||
'font-src': ["{static}"],
|
||||
'media-src': ["{static}", "data:"],
|
||||
# form-action is not only used to match on form actions, but also on URLs
|
||||
|
||||
@@ -182,12 +182,12 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Slug'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Slug'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
|
||||
@@ -23,12 +23,12 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Slug'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Slug'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
|
||||
@@ -124,7 +124,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='requiredaction',
|
||||
@@ -179,7 +179,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=merge_names,
|
||||
|
||||
@@ -346,7 +346,7 @@ class Migration(migrations.Migration):
|
||||
help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily remembered, but you can also choose to use a random value. This will be used in URLs, order codes, invoice numbers, and bank transfer references.',
|
||||
validators=[django.core.validators.RegexValidator(
|
||||
message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'),
|
||||
pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoice',
|
||||
|
||||
@@ -33,7 +33,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='requiredaction',
|
||||
|
||||
@@ -36,7 +36,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.RunPython(merge_names, migrations.RunPython.noop)
|
||||
]
|
||||
|
||||
@@ -38,7 +38,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily remembered, but you can also choose to use a random value. This will be used in URLs, order codes, invoice numbers, and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily remembered, but you can also choose to use a random value. This will be used in URLs, order codes, invoice numbers, and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoice',
|
||||
|
||||
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily remembered, but you can also choose to use a random value. This will be used in URLs, order codes, invoice numbers, and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily remembered, but you can also choose to use a random value. This will be used in URLs, order codes, invoice numbers, and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='eventmetaproperty',
|
||||
@@ -54,7 +54,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used once. This is being used in URLs to refer to your organizer accounts and your events.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used once. This is being used in URLs to refer to your organizer accounts and your events.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CheckinList',
|
||||
|
||||
@@ -105,7 +105,7 @@ class Migration(migrations.Migration):
|
||||
'references.',
|
||||
validators=[django.core.validators.RegexValidator(
|
||||
message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'),
|
||||
pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='eventmetaproperty',
|
||||
@@ -125,7 +125,7 @@ class Migration(migrations.Migration):
|
||||
' events.',
|
||||
validators=[django.core.validators.RegexValidator(
|
||||
message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'),
|
||||
pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CheckinList',
|
||||
|
||||
@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used once. This is being used in URLs to refer to your organizer accounts and your events.', unique=True, validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used once. This is being used in URLs to refer to your organizer accounts and your events.', unique=True, validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='staffsession',
|
||||
|
||||
@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used once. This is being used in URLs to refer to your organizer accounts and your events.', unique=True, validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used once. This is being used in URLs to refer to your organizer accounts and your events.', unique=True, validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='staffsession',
|
||||
|
||||
17
src/pretix/base/migrations/0137_auto_20191015_1141.py
Normal file
17
src/pretix/base/migrations/0137_auto_20191015_1141.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 2.2.4 on 2019-10-15 11:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0136_auto_20190918_1742'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='auth_backend',
|
||||
field=models.CharField(default='native', max_length=255),
|
||||
),
|
||||
]
|
||||
107
src/pretix/base/migrations/0138_auto_20191017_1151.py
Normal file
107
src/pretix/base/migrations/0138_auto_20191017_1151.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# Generated by Django 2.2.4 on 2019-10-17 11:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.fields
|
||||
import pretix.base.models.giftcards
|
||||
|
||||
|
||||
def fwd(app, schema_editor):
|
||||
Team = app.get_model('pretixbase', 'Team')
|
||||
Team.objects.filter(can_change_organizer_settings=True).update(can_manage_gift_cards=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0137_auto_20191015_1141'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GiftCard',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('issuance', models.DateTimeField(auto_now_add=True)),
|
||||
('secret', models.CharField(db_index=True, default=pretix.base.models.giftcards.gen_giftcard_secret,
|
||||
max_length=190)),
|
||||
('currency', models.CharField(max_length=10)),
|
||||
('issued_in', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='issued_gift_cards', to='pretixbase.OrderPosition')),
|
||||
('issuer',
|
||||
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_gift_cards',
|
||||
to='pretixbase.Organizer')),
|
||||
('testmode', django.db.models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('secret', 'issuer')},
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='issue_giftcard',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='can_manage_gift_cards',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='question',
|
||||
name='dependency_values',
|
||||
field=pretix.base.models.fields.MultiStringField(default=[]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='item',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vouchers',
|
||||
to='pretixbase.Item'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='quota',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vouchers',
|
||||
to='pretixbase.Quota'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='variation',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vouchers',
|
||||
to='pretixbase.ItemVariation'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GiftCardTransaction',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('datetime', models.DateTimeField(auto_now_add=True)),
|
||||
('value', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions',
|
||||
to='pretixbase.GiftCard')),
|
||||
('order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='gift_card_transactions', to='pretixbase.Order')),
|
||||
('payment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='gift_card_transactions', to='pretixbase.OrderPayment')),
|
||||
('refund', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='gift_card_transactions', to='pretixbase.OrderRefund')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('datetime',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GiftCardAcceptance',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('collector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='gift_card_issuer_acceptance', to='pretixbase.Organizer')),
|
||||
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='gift_card_collector_acceptance', to='pretixbase.Organizer')),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
fwd, migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
33
src/pretix/base/migrations/0139_auto_20191019_1317.py
Normal file
33
src/pretix/base/migrations/0139_auto_20191019_1317.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 2.2.1 on 2019-10-19 13:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0138_auto_20191017_1151'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='geo_lat',
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='geo_lon',
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='geo_lat',
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='geo_lon',
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
]
|
||||
19
src/pretix/base/migrations/0140_voucher_seat.py
Normal file
19
src/pretix/base/migrations/0140_voucher_seat.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.4 on 2019-11-14 11:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0139_auto_20191019_1317'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='seat',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vouchers', to='pretixbase.Seat'),
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0141_seat_sorting_rank.py
Normal file
18
src/pretix/base/migrations/0141_seat_sorting_rank.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.4 on 2019-11-22 11:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0140_voucher_seat'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='seat',
|
||||
name='sorting_rank',
|
||||
field=models.BigIntegerField(default=0),
|
||||
),
|
||||
]
|
||||
30
src/pretix/base/migrations/0142_auto_20191215_1522.py
Normal file
30
src/pretix/base/migrations/0142_auto_20191215_1522.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 2.2.7 on 2019-12-15 15:22
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0141_seat_sorting_rank'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='price_before_voucher',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='price_before_voucher',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='budget',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
|
||||
),
|
||||
]
|
||||
@@ -7,6 +7,7 @@ from .event import (
|
||||
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
|
||||
RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token,
|
||||
)
|
||||
from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question,
|
||||
|
||||
@@ -109,6 +109,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
help_text=_('If turned off, you will not get any notifications.')
|
||||
)
|
||||
notifications_token = models.CharField(max_length=255, default=generate_notifications_token)
|
||||
auth_backend = models.CharField(max_length=255, default='native')
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
@@ -334,6 +335,25 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
| Q(id__in=self.teams.filter(**kwargs).values_list('limit_events__id', flat=True))
|
||||
)
|
||||
|
||||
@scopes_disabled()
|
||||
def get_organizers_with_permission(self, permission, request=None):
|
||||
"""
|
||||
Returns a queryset of organizers the user has a specific permissions to.
|
||||
|
||||
:param request: The current request (optional). Required to detect staff sessions properly.
|
||||
:return: Iterable of Organizers
|
||||
"""
|
||||
from .event import Organizer
|
||||
|
||||
if request and self.has_active_staff_session(request.session.session_key):
|
||||
return Organizer.objects.all()
|
||||
|
||||
kwargs = {permission: True}
|
||||
|
||||
return Organizer.objects.filter(
|
||||
id__in=self.teams.filter(**kwargs).values_list('organizer', flat=True)
|
||||
)
|
||||
|
||||
def has_active_staff_session(self, session_key=None):
|
||||
"""
|
||||
Returns whether or not a user has an active staff session (formerly known as superuser session)
|
||||
|
||||
@@ -3,6 +3,7 @@ import uuid
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, time, timedelta
|
||||
from operator import attrgetter
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
@@ -11,7 +12,7 @@ from django.core.files.storage import default_storage
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models import Exists, F, 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,10 +23,11 @@ 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.base.validators import EventSlugBanlistValidator
|
||||
from pretix.helpers.database import GroupConcat
|
||||
from pretix.helpers.daterange import daterange
|
||||
from pretix.helpers.json import safe_string
|
||||
from pretix.helpers.thumb import get_thumbnail
|
||||
|
||||
from ..settings import settings_hierarkey
|
||||
from .organizer import Organizer, Team
|
||||
@@ -145,10 +147,13 @@ class EventMixin:
|
||||
"@context": "http://schema.org",
|
||||
"@type": "Event", "location": {
|
||||
"@type": "Place",
|
||||
"address": str(self.location)
|
||||
"address": str(self.location),
|
||||
},
|
||||
"name": str(self.name)
|
||||
"name": str(self.name),
|
||||
}
|
||||
img = getattr(self, 'event', self).social_image
|
||||
if img:
|
||||
eventdict['image'] = img
|
||||
|
||||
if self.settings.show_times:
|
||||
eventdict["startDate"] = self.date_from.isoformat()
|
||||
@@ -291,7 +296,7 @@ class Event(EventMixin, LoggedModel):
|
||||
regex="^[a-zA-Z0-9.-]+$",
|
||||
message=_("The slug may only contain letters, numbers, dots and dashes."),
|
||||
),
|
||||
EventSlugBlacklistValidator()
|
||||
EventSlugBanlistValidator()
|
||||
],
|
||||
verbose_name=_("Short form"),
|
||||
)
|
||||
@@ -324,6 +329,14 @@ class Event(EventMixin, LoggedModel):
|
||||
max_length=200,
|
||||
verbose_name=_("Location"),
|
||||
)
|
||||
geo_lat = models.FloatField(
|
||||
verbose_name=_("Latitude"),
|
||||
null=True, blank=True,
|
||||
)
|
||||
geo_lon = models.FloatField(
|
||||
verbose_name=_("Longitude"),
|
||||
null=True, blank=True,
|
||||
)
|
||||
plugins = models.TextField(
|
||||
null=False, blank=True,
|
||||
verbose_name=_("Plugins"),
|
||||
@@ -350,10 +363,41 @@ class Event(EventMixin, LoggedModel):
|
||||
def __str__(self):
|
||||
return str(self.name)
|
||||
|
||||
def set_defaults(self):
|
||||
"""
|
||||
This will be called after event creation, but only if the event was not created by copying an existing one.
|
||||
This way, we can use this to introduce new default settings to pretix that do not affect existing events.
|
||||
"""
|
||||
self.settings.invoice_renderer = 'modern1'
|
||||
self.settings.invoice_include_expire_date = True
|
||||
|
||||
@property
|
||||
def free_seats(self):
|
||||
def social_image(self):
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
img = None
|
||||
logo_file = self.settings.get('logo_image', as_type=str, default='')[7:]
|
||||
og_file = self.settings.get('og_image', as_type=str, default='')[7:]
|
||||
if og_file:
|
||||
img = get_thumbnail(og_file, '1200').thumb.url
|
||||
elif logo_file:
|
||||
img = get_thumbnail(logo_file, '5000x120').thumb.url
|
||||
if img:
|
||||
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web'):
|
||||
from .orders import CartPosition, Order, OrderPosition
|
||||
return self.seats.annotate(
|
||||
from .vouchers import Voucher
|
||||
vqs = Voucher.objects.filter(
|
||||
event=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
redeemed__lt=F('max_usages'),
|
||||
).filter(
|
||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now())
|
||||
)
|
||||
if ignore_voucher:
|
||||
vqs = vqs.exclude(pk=ignore_voucher.pk)
|
||||
qs = self.seats.annotate(
|
||||
has_order=Exists(
|
||||
OrderPosition.objects.filter(
|
||||
order__event=self,
|
||||
@@ -367,8 +411,14 @@ class Event(EventMixin, LoggedModel):
|
||||
seat_id=OuterRef('pk'),
|
||||
expires__gte=now()
|
||||
)
|
||||
),
|
||||
has_voucher=Exists(
|
||||
vqs
|
||||
)
|
||||
).filter(has_order=False, has_cart=False, blocked=False)
|
||||
).filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if sales_channel not in self.settings.seating_allow_blocked_seats_for_channel:
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
@@ -472,6 +522,7 @@ class Event(EventMixin, LoggedModel):
|
||||
self.is_public = other.is_public
|
||||
self.testmode = other.testmode
|
||||
self.save()
|
||||
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
|
||||
|
||||
tax_map = {}
|
||||
for t in other.tax_rules.all():
|
||||
@@ -479,6 +530,7 @@ class Event(EventMixin, LoggedModel):
|
||||
t.pk = None
|
||||
t.event = self
|
||||
t.save()
|
||||
t.log_action('pretix.object.cloned')
|
||||
|
||||
category_map = {}
|
||||
for c in ItemCategory.objects.filter(event=other):
|
||||
@@ -486,6 +538,7 @@ class Event(EventMixin, LoggedModel):
|
||||
c.pk = None
|
||||
c.event = self
|
||||
c.save()
|
||||
c.log_action('pretix.object.cloned')
|
||||
|
||||
item_map = {}
|
||||
variation_map = {}
|
||||
@@ -501,6 +554,7 @@ class Event(EventMixin, LoggedModel):
|
||||
if i.tax_rule_id:
|
||||
i.tax_rule = tax_map[i.tax_rule_id]
|
||||
i.save()
|
||||
i.log_action('pretix.object.cloned')
|
||||
for v in vars:
|
||||
variation_map[v.pk] = v
|
||||
v.pk = None
|
||||
@@ -525,6 +579,7 @@ class Event(EventMixin, LoggedModel):
|
||||
q.cached_availability_time = None
|
||||
q.closed = False
|
||||
q.save()
|
||||
q.log_action('pretix.object.cloned')
|
||||
for i in items:
|
||||
if i.pk in item_map:
|
||||
q.items.add(item_map[i.pk])
|
||||
@@ -540,6 +595,7 @@ class Event(EventMixin, LoggedModel):
|
||||
q.pk = None
|
||||
q.event = self
|
||||
q.save()
|
||||
q.log_action('pretix.object.cloned')
|
||||
|
||||
for i in items:
|
||||
q.items.add(item_map[i.pk])
|
||||
@@ -557,6 +613,7 @@ class Event(EventMixin, LoggedModel):
|
||||
cl.pk = None
|
||||
cl.event = self
|
||||
cl.save()
|
||||
cl.log_action('pretix.object.cloned')
|
||||
for i in items:
|
||||
cl.limit_products.add(item_map[i.pk])
|
||||
|
||||
@@ -624,7 +681,9 @@ class Event(EventMixin, LoggedModel):
|
||||
pp = p(self)
|
||||
providers[pp.identifier] = pp
|
||||
|
||||
self._cached_payment_providers = OrderedDict(sorted(providers.items(), key=lambda v: str(v[1].verbose_name)))
|
||||
self._cached_payment_providers = OrderedDict(sorted(
|
||||
providers.items(), key=lambda v: (-v[1].priority, str(v[1].verbose_name))
|
||||
))
|
||||
return self._cached_payment_providers
|
||||
|
||||
def get_html_mail_renderer(self):
|
||||
@@ -730,7 +789,7 @@ class Event(EventMixin, LoggedModel):
|
||||
def has_payment_provider(self):
|
||||
result = False
|
||||
for provider in self.get_payment_providers().values():
|
||||
if provider.is_enabled and provider.identifier not in ('free', 'boxoffice', 'offsetting'):
|
||||
if provider.is_enabled and provider.identifier not in ('free', 'boxoffice', 'offsetting', 'giftcard'):
|
||||
result = True
|
||||
break
|
||||
return result
|
||||
@@ -929,6 +988,14 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
max_length=200,
|
||||
verbose_name=_("Location"),
|
||||
)
|
||||
geo_lat = models.FloatField(
|
||||
verbose_name=_("Latitude"),
|
||||
null=True, blank=True,
|
||||
)
|
||||
geo_lon = models.FloatField(
|
||||
verbose_name=_("Longitude"),
|
||||
null=True, blank=True
|
||||
)
|
||||
frontpage_text = I18nTextField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Frontpage text")
|
||||
@@ -949,10 +1016,20 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
def __str__(self):
|
||||
return '{} - {}'.format(self.name, self.get_date_range_display())
|
||||
|
||||
@property
|
||||
def free_seats(self):
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web'):
|
||||
from .orders import CartPosition, Order, OrderPosition
|
||||
return self.seats.annotate(
|
||||
from .vouchers import Voucher
|
||||
vqs = Voucher.objects.filter(
|
||||
event_id=self.event_id,
|
||||
subevent=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
redeemed__lt=F('max_usages'),
|
||||
).filter(
|
||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now())
|
||||
)
|
||||
if ignore_voucher:
|
||||
vqs = vqs.exclude(pk=ignore_voucher.pk)
|
||||
qs = self.seats.annotate(
|
||||
has_order=Exists(
|
||||
OrderPosition.objects.filter(
|
||||
order__event_id=self.event_id,
|
||||
@@ -968,8 +1045,14 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
seat_id=OuterRef('pk'),
|
||||
expires__gte=now()
|
||||
)
|
||||
),
|
||||
has_voucher=Exists(
|
||||
vqs
|
||||
)
|
||||
).filter(has_order=False, has_cart=False, blocked=False)
|
||||
).filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if sales_channel not in self.settings.seating_allow_blocked_seats_for_channel:
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
|
||||
@cached_property
|
||||
def settings(self):
|
||||
|
||||
117
src/pretix/base/models/giftcards.py
Normal file
117
src/pretix/base/models/giftcards.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.models import LoggedModel
|
||||
|
||||
|
||||
def gen_giftcard_secret(length):
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
while True:
|
||||
code = get_random_string(length=length, allowed_chars=charset)
|
||||
if not banned(code) and not GiftCard.objects.filter(secret=code).exists():
|
||||
return code
|
||||
|
||||
|
||||
class GiftCardAcceptance(models.Model):
|
||||
issuer = models.ForeignKey(
|
||||
'Organizer',
|
||||
related_name='gift_card_collector_acceptance',
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
collector = models.ForeignKey(
|
||||
'Organizer',
|
||||
related_name='gift_card_issuer_acceptance',
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
|
||||
class GiftCard(LoggedModel):
|
||||
issuer = models.ForeignKey(
|
||||
'Organizer',
|
||||
related_name='issued_gift_cards',
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
issued_in = models.ForeignKey(
|
||||
'OrderPosition',
|
||||
related_name='issued_gift_cards',
|
||||
on_delete=models.PROTECT,
|
||||
null=True, blank=True
|
||||
)
|
||||
issuance = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
)
|
||||
secret = models.CharField(
|
||||
max_length=190,
|
||||
db_index=True,
|
||||
verbose_name=_('Gift card code'),
|
||||
)
|
||||
testmode = models.BooleanField(
|
||||
verbose_name=_('Test mode card'),
|
||||
default=False
|
||||
)
|
||||
CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES]
|
||||
currency = models.CharField(max_length=10, choices=CURRENCY_CHOICES)
|
||||
|
||||
def __str__(self):
|
||||
return self.secret
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self.transactions.aggregate(s=Sum('value'))['s'] or Decimal('0.00')
|
||||
|
||||
def accepted_by(self, organizer):
|
||||
return self.issuer == organizer or GiftCardAcceptance.objects.filter(issuer=self.issuer, collector=organizer).exists()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.secret:
|
||||
self.secret = gen_giftcard_secret(self.issuer.settings.giftcard_length)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
unique_together = (('secret', 'issuer'),)
|
||||
|
||||
|
||||
class GiftCardTransaction(models.Model):
|
||||
card = models.ForeignKey(
|
||||
'GiftCard',
|
||||
related_name='transactions',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
datetime = models.DateTimeField(
|
||||
auto_now_add=True
|
||||
)
|
||||
value = models.DecimalField(
|
||||
decimal_places=2,
|
||||
max_digits=10
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
'Order',
|
||||
related_name='gift_card_transactions',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
payment = models.ForeignKey(
|
||||
'OrderPayment',
|
||||
related_name='gift_card_transactions',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
refund = models.ForeignKey(
|
||||
'OrderRefund',
|
||||
related_name='gift_card_transactions',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ("datetime",)
|
||||
@@ -191,6 +191,9 @@ class Invoice(models.Model):
|
||||
self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-')
|
||||
if self.is_cancellation:
|
||||
self.prefix = self.event.settings.invoice_numbers_prefix_cancellations or self.prefix
|
||||
if '%' in self.prefix:
|
||||
self.prefix = self.date.strftime(self.prefix)
|
||||
|
||||
if not self.invoice_no:
|
||||
if self.order.testmode:
|
||||
self.prefix += 'TEST-'
|
||||
|
||||
@@ -175,8 +175,6 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
|
||||
q &= Q(pk=voucher.item_id)
|
||||
elif voucher.quota_id:
|
||||
q &= Q(quotas__in=[voucher.quota_id])
|
||||
else:
|
||||
return qs.none()
|
||||
if not voucher or not voucher.show_hidden_items:
|
||||
q &= Q(hide_without_voucher=False)
|
||||
|
||||
@@ -242,6 +240,8 @@ class Item(LoggedModel):
|
||||
:type require_approval: bool
|
||||
:param sales_channels: Sales channels this item is available on.
|
||||
:type sales_channels: bool
|
||||
:param issue_giftcard: If ``True``, buying this product will give you a gift card with the value of the product's price
|
||||
:type issue_giftcard: bool
|
||||
"""
|
||||
|
||||
objects = ItemQuerySetManager()
|
||||
@@ -413,6 +413,12 @@ class Item(LoggedModel):
|
||||
verbose_name=_('Sales channels'),
|
||||
default=['web']
|
||||
)
|
||||
issue_giftcard = models.BooleanField(
|
||||
verbose_name=_('This product is a gift card'),
|
||||
help_text=_('When a customer buys this product, they will get a gift card with a value corresponding to the '
|
||||
'product price.'),
|
||||
default=False
|
||||
)
|
||||
# !!! Attention: If you add new fields here, also add them to the copying code in
|
||||
# pretix/control/forms/item.py if applicable.
|
||||
|
||||
@@ -969,6 +975,7 @@ class Question(LoggedModel):
|
||||
TYPE_TIME = "H"
|
||||
TYPE_DATETIME = "W"
|
||||
TYPE_COUNTRYCODE = "CC"
|
||||
TYPE_PHONENUMBER = "TEL"
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_NUMBER, _("Number")),
|
||||
(TYPE_STRING, _("Text (one line)")),
|
||||
@@ -981,8 +988,10 @@ class Question(LoggedModel):
|
||||
(TYPE_TIME, _("Time")),
|
||||
(TYPE_DATETIME, _("Date and time")),
|
||||
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
|
||||
(TYPE_PHONENUMBER, _("Phone number")),
|
||||
)
|
||||
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
|
||||
ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE, TYPE_PHONENUMBER]
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
@@ -1097,17 +1106,25 @@ class Question(LoggedModel):
|
||||
|
||||
if self.type == Question.TYPE_CHOICE:
|
||||
try:
|
||||
return self.options.get(pk=answer)
|
||||
return self.options.get(Q(pk=answer) | Q(identifier=answer))
|
||||
except:
|
||||
raise ValidationError(_('Invalid option selected.'))
|
||||
elif self.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
try:
|
||||
if isinstance(answer, str):
|
||||
return list(self.options.filter(pk__in=answer.split(",")))
|
||||
else:
|
||||
return list(self.options.filter(pk__in=answer))
|
||||
except:
|
||||
if isinstance(answer, str):
|
||||
l_ = list(self.options.filter(
|
||||
Q(pk__in=[a for a in answer.split(",") if a.isdigit()]) |
|
||||
Q(identifier__in=answer.split(","))
|
||||
))
|
||||
llen = len(answer.split(','))
|
||||
else:
|
||||
l_ = list(self.options.filter(
|
||||
Q(pk__in=[a for a in answer if isinstance(a, int) or a.isdigit()]) |
|
||||
Q(identifier__in=answer)
|
||||
))
|
||||
llen = len(answer)
|
||||
if len(l_) != llen:
|
||||
raise ValidationError(_('Invalid option selected.'))
|
||||
return l_
|
||||
elif self.type == Question.TYPE_BOOLEAN:
|
||||
return answer in ('true', 'True', True)
|
||||
elif self.type == Question.TYPE_NUMBER:
|
||||
|
||||
@@ -30,6 +30,8 @@ from django_countries.fields import Country, CountryField
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
from phonenumber_field.phonenumber import PhoneNumber
|
||||
from phonenumbers import NumberParseException
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.decimal import round_decimal
|
||||
@@ -39,6 +41,7 @@ from pretix.base.models import User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.locking import NoLockManager
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import order_gracefully_delete
|
||||
|
||||
from .base import LockModel, LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
@@ -201,7 +204,7 @@ class Order(LockModel, LoggedModel):
|
||||
return self.full_code
|
||||
|
||||
def gracefully_delete(self, user=None, auth=None):
|
||||
from . import Voucher
|
||||
from . import Voucher, GiftCard, GiftCardTransaction
|
||||
|
||||
if not self.testmode:
|
||||
raise TypeError("Only test mode orders can be deleted.")
|
||||
@@ -212,11 +215,17 @@ class Order(LockModel, LoggedModel):
|
||||
}
|
||||
)
|
||||
|
||||
order_gracefully_delete.send(self.event, order=self)
|
||||
|
||||
if self.status != Order.STATUS_CANCELED:
|
||||
for position in self.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
|
||||
GiftCardTransaction.objects.filter(payment__in=self.payments.all()).update(payment=None)
|
||||
GiftCardTransaction.objects.filter(refund__in=self.refunds.all()).update(refund=None)
|
||||
GiftCardTransaction.objects.filter(order=self).update(order=None)
|
||||
GiftCard.objects.filter(issued_in__in=self.positions.all()).update(issued_in=None)
|
||||
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
|
||||
OrderPosition.all.filter(order=self).delete()
|
||||
OrderFee.all.filter(order=self).delete()
|
||||
@@ -458,11 +467,15 @@ class Order(LockModel, LoggedModel):
|
||||
positions = list(
|
||||
self.positions.all().annotate(
|
||||
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
|
||||
).select_related('item')
|
||||
).select_related('item').prefetch_related('issued_gift_cards')
|
||||
)
|
||||
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
|
||||
if not cancelable or not positions:
|
||||
return False
|
||||
for op in positions:
|
||||
for gc in op.issued_gift_cards.all():
|
||||
if gc.value != op.price:
|
||||
return False
|
||||
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||
return False
|
||||
if self.status == Order.STATUS_PENDING:
|
||||
@@ -674,10 +687,12 @@ class Order(LockModel, LoggedModel):
|
||||
error_messages = {
|
||||
'unavailable': _('The ordered product "{item}" is no longer available.'),
|
||||
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
|
||||
'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'),
|
||||
}
|
||||
now_dt = now_dt or now()
|
||||
positions = self.positions.all().select_related('item', 'variation', 'seat')
|
||||
positions = self.positions.all().select_related('item', 'variation', 'seat', 'voucher')
|
||||
quota_cache = {}
|
||||
v_budget = {}
|
||||
try:
|
||||
for i, op in enumerate(positions):
|
||||
if op.seat:
|
||||
@@ -686,6 +701,16 @@ class Order(LockModel, LoggedModel):
|
||||
if force:
|
||||
continue
|
||||
|
||||
if op.voucher and op.voucher.budget is not None and op.price_before_voucher is not None:
|
||||
if op.voucher not in v_budget:
|
||||
v_budget[op.voucher] = op.voucher.budget - op.voucher.budget_used()
|
||||
disc = op.price_before_voucher - op.price
|
||||
if disc > v_budget[op.voucher]:
|
||||
raise Quota.QuotaExceededException(error_messages['voucher_budget'].format(
|
||||
voucher=op.voucher.code
|
||||
))
|
||||
v_budget[op.voucher] -= disc
|
||||
|
||||
quotas = list(op.quotas)
|
||||
if len(quotas) == 0:
|
||||
raise Quota.QuotaExceededException(error_messages['unavailable'].format(
|
||||
@@ -713,7 +738,8 @@ class Order(LockModel, LoggedModel):
|
||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True):
|
||||
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
|
||||
attach_ical=False):
|
||||
"""
|
||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||
|
||||
@@ -730,6 +756,7 @@ class Order(LockModel, LoggedModel):
|
||||
:param headers: Dictionary with additional mail headers
|
||||
:param sender: Custom email sender.
|
||||
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
||||
:param attach_ical: Attach relevant ICS files
|
||||
:param position: An order position this refers to. If given, no invoices will be attached, the tickets will
|
||||
only be attached for this position and child positions, the link will only point to the
|
||||
position and the attendee email will be used if available.
|
||||
@@ -753,7 +780,7 @@ class Order(LockModel, LoggedModel):
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers=headers, sender=sender,
|
||||
invoices=invoices, attach_tickets=attach_tickets,
|
||||
position=position, auto_email=auto_email
|
||||
position=position, auto_email=auto_email, attach_ical=attach_ical
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
@@ -769,6 +796,7 @@ class Order(LockModel, LoggedModel):
|
||||
'recipient': recipient,
|
||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||
'attach_tickets': attach_tickets,
|
||||
'attach_ical': attach_ical,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -780,7 +808,7 @@ class Order(LockModel, LoggedModel):
|
||||
self.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.resend', user=user, auth=auth,
|
||||
attach_tickets=True
|
||||
attach_tickets=True,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -908,6 +936,11 @@ class QuestionAnswer(models.Model):
|
||||
return self.answer
|
||||
elif self.question.type == Question.TYPE_COUNTRYCODE and self.answer:
|
||||
return Country(self.answer).name or self.answer
|
||||
elif self.question.type == Question.TYPE_PHONENUMBER and self.answer:
|
||||
try:
|
||||
return PhoneNumber.from_string(self.answer).as_international
|
||||
except NumberParseException:
|
||||
return self.answer
|
||||
else:
|
||||
return self.answer
|
||||
|
||||
@@ -970,6 +1003,9 @@ class AbstractPosition(models.Model):
|
||||
verbose_name=_("Variation"),
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
price_before_voucher = models.DecimalField(
|
||||
decimal_places=2, max_digits=10, null=True,
|
||||
)
|
||||
price = models.DecimalField(
|
||||
decimal_places=2, max_digits=10,
|
||||
verbose_name=_("Price")
|
||||
@@ -1043,6 +1079,8 @@ class AbstractPosition(models.Model):
|
||||
}
|
||||
|
||||
def question_is_visible(parentid, qvals):
|
||||
if parentid not in question_cache:
|
||||
return False
|
||||
parentq = question_cache[parentid]
|
||||
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
|
||||
return False
|
||||
@@ -1197,6 +1235,7 @@ class OrderPayment(models.Model):
|
||||
"""
|
||||
return self.order.event.get_payment_providers(cached=True).get(self.provider)
|
||||
|
||||
@transaction.atomic()
|
||||
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
|
||||
from pretix.base.signals import order_paid
|
||||
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force)
|
||||
@@ -1265,7 +1304,7 @@ class OrderPayment(models.Model):
|
||||
'provider': self.provider,
|
||||
}, user=user, auth=auth)
|
||||
|
||||
if self.order.status == Order.STATUS_PAID:
|
||||
if self.order.status in (Order.STATUS_PAID, Order.STATUS_CANCELED):
|
||||
return
|
||||
|
||||
payment_sum = self.order.payments.filter(
|
||||
@@ -1322,7 +1361,8 @@ class OrderPayment(models.Model):
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_paid', user,
|
||||
invoices=[], position=position,
|
||||
attach_tickets=True
|
||||
attach_tickets=True,
|
||||
attach_ical=self.order.event.settings.mail_attach_ical
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order paid email could not be sent')
|
||||
@@ -1339,7 +1379,8 @@ class OrderPayment(models.Model):
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_paid', user,
|
||||
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True
|
||||
attach_tickets=True,
|
||||
attach_ical=self.order.event.settings.mail_attach_ical
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order paid email could not be sent')
|
||||
@@ -2007,7 +2048,7 @@ class InvoiceAddress(models.Model):
|
||||
internal_reference = models.TextField(
|
||||
verbose_name=_('Internal reference'),
|
||||
help_text=_('This reference will be printed on your invoice for your convenience.'),
|
||||
blank=True
|
||||
blank=True,
|
||||
)
|
||||
beneficiary = models.TextField(
|
||||
verbose_name=_('Beneficiary'),
|
||||
@@ -2027,6 +2068,13 @@ class InvoiceAddress(models.Model):
|
||||
self.name_parts = {}
|
||||
super().save(**kwargs)
|
||||
|
||||
@property
|
||||
def is_empty(self):
|
||||
return (
|
||||
not self.name_cached and not self.company and not self.street and not self.zipcode and not self.city
|
||||
and not self.internal_reference and not self.beneficiary
|
||||
)
|
||||
|
||||
@property
|
||||
def state_name(self):
|
||||
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||
|
||||
@@ -2,12 +2,13 @@ import string
|
||||
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.validators import OrganizerSlugBlacklistValidator
|
||||
from pretix.base.validators import OrganizerSlugBanlistValidator
|
||||
|
||||
from ..settings import settings_hierarkey
|
||||
from .auth import User
|
||||
@@ -39,7 +40,7 @@ class Organizer(LoggedModel):
|
||||
regex="^[a-zA-Z0-9.-]+$",
|
||||
message=_("The slug may only contain letters, numbers, dots and dashes.")
|
||||
),
|
||||
OrganizerSlugBlacklistValidator()
|
||||
OrganizerSlugBanlistValidator()
|
||||
],
|
||||
verbose_name=_("Short form"),
|
||||
unique=True
|
||||
@@ -82,6 +83,24 @@ class Organizer(LoggedModel):
|
||||
|
||||
return ObjectRelatedCache(self)
|
||||
|
||||
@property
|
||||
def has_gift_cards(self):
|
||||
return self.cache.get_or_set(
|
||||
key='has_gift_cards',
|
||||
timeout=15,
|
||||
default=lambda: self.issued_gift_cards.exists() or self.gift_card_issuer_acceptance.exists()
|
||||
)
|
||||
|
||||
@property
|
||||
def accepted_gift_cards(self):
|
||||
from .giftcards import GiftCard, GiftCardAcceptance
|
||||
|
||||
return GiftCard.objects.annotate(
|
||||
accepted=Exists(GiftCardAcceptance.objects.filter(issuer=OuterRef('issuer'), collector=self))
|
||||
).filter(
|
||||
Q(issuer=self) | Q(accepted=True)
|
||||
)
|
||||
|
||||
def allow_delete(self):
|
||||
from . import Order, Invoice
|
||||
return (
|
||||
@@ -156,6 +175,10 @@ class Team(LoggedModel):
|
||||
help_text=_('Someone with this setting can get access to most data of all of your events, i.e. via privacy '
|
||||
'reports, so be careful who you add to this team!')
|
||||
)
|
||||
can_manage_gift_cards = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can manage gift cards")
|
||||
)
|
||||
|
||||
can_change_event_settings = models.BooleanField(
|
||||
default=False,
|
||||
|
||||
@@ -5,6 +5,7 @@ import jsonschema
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext, ugettext_lazy as _
|
||||
@@ -39,7 +40,7 @@ class SeatingPlan(LoggedModel):
|
||||
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
|
||||
|
||||
Category = namedtuple('Categrory', 'name')
|
||||
RawSeat = namedtuple('Seat', 'name guid number row category zone')
|
||||
RawSeat = namedtuple('Seat', 'name guid number row category zone sorting_rank')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -59,16 +60,36 @@ class SeatingPlan(LoggedModel):
|
||||
]
|
||||
|
||||
def iter_all_seats(self):
|
||||
for z in self.layout_data['zones']:
|
||||
for r in z['rows']:
|
||||
for s in r['seats']:
|
||||
# This returns all seats in a plan and assignes each of them a rank. The rank is used for sorting lists of
|
||||
# seats later. The rank does not say anything about the *quality* of a seat, and is only meant as a heuristic
|
||||
# to make it easier for humas to process lists of seats. The current algorithm assumes that there are less
|
||||
# than 10'000 zones, less than 10'000 rows in every zone and less than 10'000 seats in every row.
|
||||
# Respectively, no row/seat numbers may be numeric with a value of 10'000 or more. The resulting ranks
|
||||
# *will* have gaps. We chose this way over just sorting the seats and continuously enumerating them as an
|
||||
# optimization, because this way we do not need to update the rank of very seat if we change a plan a little.
|
||||
for zi, z in enumerate(self.layout_data['zones']):
|
||||
for ri, r in enumerate(z['rows']):
|
||||
try:
|
||||
row_rank = int(r['row_number'])
|
||||
except ValueError:
|
||||
row_rank = ri
|
||||
for si, s in enumerate(r['seats']):
|
||||
try:
|
||||
seat_rank = int(s['seat_number'])
|
||||
except ValueError:
|
||||
seat_rank = si
|
||||
rank = (
|
||||
10000 * 10000 * zi + 10000 * row_rank + seat_rank
|
||||
)
|
||||
|
||||
yield self.RawSeat(
|
||||
number=s['seat_number'],
|
||||
guid=s['seat_guid'],
|
||||
name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme?
|
||||
row=r['row_number'],
|
||||
zone=z['name'],
|
||||
category=s['category']
|
||||
category=s['category'],
|
||||
sorting_rank=rank
|
||||
)
|
||||
|
||||
|
||||
@@ -97,6 +118,10 @@ class Seat(models.Model):
|
||||
seat_guid = models.CharField(max_length=190, db_index=True)
|
||||
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
|
||||
blocked = models.BooleanField(default=False)
|
||||
sorting_rank = models.BigIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ['sorting_rank', 'seat_guid']
|
||||
|
||||
def __str__(self):
|
||||
parts = []
|
||||
@@ -110,15 +135,24 @@ class Seat(models.Model):
|
||||
return self.name
|
||||
return ', '.join(parts)
|
||||
|
||||
def is_available(self, ignore_cart=None, ignore_orderpos=None):
|
||||
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, sales_channel='web'):
|
||||
from .orders import Order
|
||||
|
||||
if self.blocked:
|
||||
if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
|
||||
return False
|
||||
opqs = self.orderposition_set.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
|
||||
opqs = self.orderposition_set.filter(
|
||||
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID],
|
||||
canceled=False
|
||||
)
|
||||
cpqs = self.cartposition_set.filter(expires__gte=now())
|
||||
if ignore_cart:
|
||||
vqs = self.vouchers.filter(
|
||||
Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now())) &
|
||||
Q(redeemed__lt=F('max_usages'))
|
||||
)
|
||||
if ignore_cart and ignore_cart is not True:
|
||||
cpqs = cpqs.exclude(pk=ignore_cart.pk)
|
||||
if ignore_orderpos:
|
||||
opqs = opqs.exclude(pk=ignore_orderpos.pk)
|
||||
return not opqs.exists() and not cpqs.exists()
|
||||
if ignore_voucher_id:
|
||||
vqs = vqs.exclude(pk=ignore_voucher_id)
|
||||
return not opqs.exists() and (ignore_cart is True or not cpqs.exists()) and not vqs.exists()
|
||||
|
||||
@@ -198,8 +198,14 @@ class TaxRule(LoggedModel):
|
||||
rate=self.rate, name=self.name
|
||||
)
|
||||
|
||||
@property
|
||||
def _custom_rules(self):
|
||||
if not self.custom_rules:
|
||||
return []
|
||||
return json.loads(self.custom_rules)
|
||||
|
||||
def get_matching_rule(self, invoice_address):
|
||||
rules = json.loads(self.custom_rules)
|
||||
rules = self._custom_rules
|
||||
if invoice_address:
|
||||
for r in rules:
|
||||
if r['country'] == 'EU' and str(invoice_address.country) not in EU_COUNTRIES:
|
||||
@@ -216,7 +222,7 @@ class TaxRule(LoggedModel):
|
||||
return {'action': 'vat'}
|
||||
|
||||
def is_reverse_charge(self, invoice_address):
|
||||
if self.custom_rules:
|
||||
if self._custom_rules:
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
return rule['action'] == 'reverse'
|
||||
|
||||
@@ -238,7 +244,7 @@ class TaxRule(LoggedModel):
|
||||
return False
|
||||
|
||||
def tax_applicable(self, invoice_address):
|
||||
if self.custom_rules:
|
||||
if self._custom_rules:
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
return rule.get('action', 'vat') == 'vat'
|
||||
|
||||
|
||||
@@ -4,20 +4,21 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models import F, OuterRef, Q, Subquery, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.models import SeatCategoryMapping
|
||||
from pretix.base.models import Seat, SeatCategoryMapping
|
||||
|
||||
from ..decimal import round_decimal
|
||||
from .base import LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Quota
|
||||
from .orders import Order
|
||||
from .orders import Order, OrderPosition
|
||||
|
||||
|
||||
def _generate_random_code(prefix=None):
|
||||
@@ -114,6 +115,13 @@ class Voucher(LoggedModel):
|
||||
verbose_name=_("Redeemed"),
|
||||
default=0
|
||||
)
|
||||
budget = models.DecimalField(
|
||||
verbose_name=_("Maximum discount budget"),
|
||||
help_text=_("This is the maximum monetary amount that will be discounted using this voucher across all usages. "
|
||||
"If this is sum reached, the voucher can no longer be used."),
|
||||
decimal_places=2, max_digits=10,
|
||||
null=True, blank=True
|
||||
)
|
||||
valid_until = models.DateTimeField(
|
||||
blank=True, null=True, db_index=True,
|
||||
verbose_name=_("Valid until")
|
||||
@@ -171,6 +179,12 @@ class Voucher(LoggedModel):
|
||||
"If enabled, the voucher is valid for any product affected by this quota."
|
||||
)
|
||||
)
|
||||
seat = models.ForeignKey(
|
||||
Seat, related_name='vouchers',
|
||||
null=True, blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("Specific seat"),
|
||||
)
|
||||
tag = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Tag"),
|
||||
@@ -211,11 +225,12 @@ class Voucher(LoggedModel):
|
||||
self.event,
|
||||
self.quota,
|
||||
self.item,
|
||||
self.variation
|
||||
self.variation,
|
||||
seats_given=bool(self.seat)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def clean_item_properties(data, event, quota, item, variation):
|
||||
def clean_item_properties(data, event, quota, item, variation, block_quota=False, seats_given=False):
|
||||
if quota:
|
||||
if quota.event != event:
|
||||
raise ValidationError(_('You cannot select a quota that belongs to a different event.'))
|
||||
@@ -234,8 +249,12 @@ class Voucher(LoggedModel):
|
||||
'Otherwise it might be unclear which quotas to block.'))
|
||||
if item.category and item.category.is_addon:
|
||||
raise ValidationError(_('It is currently not possible to create vouchers for add-on products.'))
|
||||
else:
|
||||
raise ValidationError(_('You need to specify either a quota or a product.'))
|
||||
elif block_quota:
|
||||
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
|
||||
'tickets.'))
|
||||
elif variation:
|
||||
raise ValidationError(_('You cannot select a variation without having selected a product that provides '
|
||||
'variations.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_max_usages(data, redeemed):
|
||||
@@ -324,7 +343,8 @@ class Voucher(LoggedModel):
|
||||
elif item and not item.has_variations:
|
||||
avail = item.check_quotas(ignored_quotas=old_quotas, subevent=data.get('subevent'))
|
||||
else:
|
||||
raise ValidationError(_('You need to specify either a quota or a product.'))
|
||||
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
|
||||
'tickets.'))
|
||||
|
||||
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < cnt):
|
||||
raise ValidationError(_('You cannot create a voucher that blocks quota as the selected product or '
|
||||
@@ -335,6 +355,42 @@ class Voucher(LoggedModel):
|
||||
if 'code' in data and Voucher.objects.filter(Q(code__iexact=data['code']) & Q(event=event) & ~Q(pk=pk)).exists():
|
||||
raise ValidationError(_('A voucher with this code already exists.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_seat_id(data, item, quota, event, pk):
|
||||
try:
|
||||
if event.has_subevents:
|
||||
if not data.get('subevent'):
|
||||
raise ValidationError(_('You need to choose a date if you select a seat.'))
|
||||
seat = event.seats.select_related('product').get(
|
||||
seat_guid=data.get('seat'), subevent=data.get('subevent')
|
||||
)
|
||||
else:
|
||||
seat = event.seats.select_related('product').get(
|
||||
seat_guid=data.get('seat')
|
||||
)
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError(_('The specified seat ID "{id}" does not exist for this event.').format(
|
||||
id=data.get('seat')))
|
||||
|
||||
if not seat.is_available(ignore_voucher_id=pk, ignore_cart=True):
|
||||
raise ValidationError(_('The seat "{id}" is currently unavailable (blocked, already sold or a '
|
||||
'different voucher).').format(
|
||||
id=seat.seat_guid))
|
||||
|
||||
if quota:
|
||||
raise ValidationError(_('You need to choose a specific product if you select a seat.'))
|
||||
|
||||
if data.get('max_usages', 1) > 1:
|
||||
raise ValidationError(_('Seat-specific vouchers can only be used once.'))
|
||||
|
||||
if item and seat.product != item:
|
||||
raise ValidationError(_('You need to choose the product "{prod}" for this seat.').format(prod=seat.product))
|
||||
|
||||
if not seat.is_available(ignore_voucher_id=pk):
|
||||
raise ValidationError(_('The seat "{id}" is already sold or currently blocked.').format(id=seat.seat_guid))
|
||||
|
||||
return seat
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.code = self.code.upper()
|
||||
super().save(*args, **kwargs)
|
||||
@@ -367,7 +423,9 @@ class Voucher(LoggedModel):
|
||||
return item.quotas.filter(pk=self.quota_id).exists()
|
||||
if self.item_id and not self.variation_id:
|
||||
return self.item_id == item.pk
|
||||
return (self.item_id == item.pk) and (variation and self.variation_id == variation.pk)
|
||||
if self.item_id:
|
||||
return (self.item_id == item.pk) and (variation and self.variation_id == variation.pk)
|
||||
return True
|
||||
|
||||
def is_active(self):
|
||||
"""
|
||||
@@ -380,7 +438,7 @@ class Voucher(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def calculate_price(self, original_price: Decimal) -> Decimal:
|
||||
def calculate_price(self, original_price: Decimal, max_discount: Decimal=None) -> Decimal:
|
||||
"""
|
||||
Returns how the price given in original_price would be modified if this
|
||||
voucher is applied, i.e. replaced by a different price or reduced by a
|
||||
@@ -398,7 +456,9 @@ class Voucher(LoggedModel):
|
||||
p = original_price
|
||||
places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
|
||||
if places < 2:
|
||||
return p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
|
||||
p = p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
|
||||
if max_discount is not None:
|
||||
p = max(p, original_price - max_discount)
|
||||
return p
|
||||
return original_price
|
||||
|
||||
@@ -410,7 +470,7 @@ class Voucher(LoggedModel):
|
||||
|
||||
return Order.objects.filter(all_positions__voucher__in=[self]).distinct()
|
||||
|
||||
def seating_available(self):
|
||||
def seating_available(self, subevent):
|
||||
kwargs = {}
|
||||
if self.subevent:
|
||||
kwargs['subevent'] = self.subevent
|
||||
@@ -419,4 +479,27 @@ class Voucher(LoggedModel):
|
||||
elif self.item_id:
|
||||
return self.item.seat_category_mappings.filter(**kwargs).exists()
|
||||
else:
|
||||
return False
|
||||
return bool(subevent.seating_plan) if subevent else self.event.seating_plan
|
||||
|
||||
@classmethod
|
||||
def annotate_budget_used_orders(cls, qs):
|
||||
opq = OrderPosition.objects.filter(
|
||||
voucher_id=OuterRef('pk'),
|
||||
price_before_voucher__isnull=False,
|
||||
order__status__in=[
|
||||
Order.STATUS_PAID,
|
||||
Order.STATUS_PENDING
|
||||
]
|
||||
).order_by().values('voucher_id').annotate(s=Sum(F('price_before_voucher') - F('price'))).values('s')
|
||||
return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=10, decimal_places=2)), Decimal('0.00')))
|
||||
|
||||
def budget_used(self):
|
||||
ops = OrderPosition.objects.filter(
|
||||
voucher=self,
|
||||
price_before_voucher__isnull=False,
|
||||
order__status__in=[
|
||||
Order.STATUS_PAID,
|
||||
Order.STATUS_PENDING
|
||||
]
|
||||
).aggregate(s=Sum(F('price_before_voucher') - F('price')))['s'] or Decimal('0.00')
|
||||
return ops
|
||||
|
||||
@@ -3,7 +3,7 @@ from collections import OrderedDict, namedtuple
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, LogEntry
|
||||
from pretix.base.signals import register_notification_types
|
||||
@@ -175,12 +175,23 @@ class ParametrizedOrderNotificationType(NotificationType):
|
||||
url=order_url
|
||||
)
|
||||
n.add_attribute(_('Event'), order.event.name)
|
||||
if order.event.has_subevents:
|
||||
ses = []
|
||||
for se in self.event.subevents.filter(id__in=order.positions.values_list('subevent', flat=True)):
|
||||
ses.append('{} ({})'.format(se.name, se.get_date_range_display()))
|
||||
n.add_attribute(pgettext_lazy('subevent', 'Dates'), '\n'.join(ses))
|
||||
else:
|
||||
n.add_attribute(_('Event date'), order.event.get_date_range_display())
|
||||
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()))
|
||||
items = []
|
||||
for it in self.event.items.filter(id__in=order.positions.values_list('item', flat=True)):
|
||||
items.append(str(it.name))
|
||||
n.add_attribute(_('Purchased products'), '\n'.join(items))
|
||||
n.add_action(_('View order details'), order_url)
|
||||
return n
|
||||
|
||||
|
||||
612
src/pretix/base/orderimport.py
Normal file
612
src/pretix/base/orderimport.py
Normal file
@@ -0,0 +1,612 @@
|
||||
import re
|
||||
from decimal import Decimal, DecimalException
|
||||
|
||||
import pycountry
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import EmailValidator
|
||||
from django.utils import formats
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import (
|
||||
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
|
||||
)
|
||||
from django_countries import countries
|
||||
from django_countries.fields import Country
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.forms.questions import guess_country
|
||||
from pretix.base.models import (
|
||||
ItemVariation, OrderPosition, QuestionAnswer, QuestionOption, Seat,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.settings import (
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SCHEMES,
|
||||
)
|
||||
from pretix.base.signals import order_import_columns
|
||||
|
||||
|
||||
class ImportColumn:
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
Unique, internal name of the column.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
"""
|
||||
Human-readable description of the column
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
"""
|
||||
Initial value for the form component
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def default_value(self):
|
||||
"""
|
||||
Internal default value for the assignment of this column. Defaults to ``empty``. Return ``None`` to disable this
|
||||
option.
|
||||
"""
|
||||
return 'empty'
|
||||
|
||||
@property
|
||||
def default_label(self):
|
||||
"""
|
||||
Human-readable description of the default assignment of this column, defaults to "Keep empty".
|
||||
"""
|
||||
return gettext_lazy('Keep empty')
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
|
||||
def static_choices(self):
|
||||
"""
|
||||
This will be called when rendering the form component and allows you to return a list of values that can be
|
||||
selected by the user statically during import.
|
||||
|
||||
:return: list of 2-tuples of strings
|
||||
"""
|
||||
return []
|
||||
|
||||
def resolve(self, settings, record):
|
||||
"""
|
||||
This method will be called to get the raw value for this field, usually by either using a static value or
|
||||
inspecting the CSV file for the assigned header. You usually do not need to implement this on your own,
|
||||
the default should be fine.
|
||||
"""
|
||||
k = settings.get(self.identifier, self.default_value)
|
||||
if k == self.default_value:
|
||||
return None
|
||||
elif k.startswith('csv:'):
|
||||
return record.get(k[4:], None) or None
|
||||
elif k.startswith('static:'):
|
||||
return k[7:]
|
||||
raise ValidationError(_('Invalid setting for column "{header}".').format(header=self.verbose_name))
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
"""
|
||||
Allows you to validate the raw input value for your column. Raise ``ValidationError`` if the value is invalid.
|
||||
You do not need to include the column or row name or value in the error message as it will automatically be
|
||||
included.
|
||||
|
||||
:param value: Contains the raw value of your column as returned by ``resolve``. This can usually be ``None``,
|
||||
e.g. if the column is empty or does not exist in this row.
|
||||
:param previous_values: Dictionary containing the validated values of all columns that have already been validated.
|
||||
"""
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
"""
|
||||
This will be called to perform the actual import. You are supposed to set attributes on the ``order``, ``position``,
|
||||
or ``invoice_address`` objects based on the input ``value``. This is called *before* the actual database
|
||||
transaction, so these three objects do not yet have a primary key. If you want to create related objects, you
|
||||
need to place them into some sort of internal queue and persist them when ``save`` is called.
|
||||
"""
|
||||
pass
|
||||
|
||||
def save(self, order):
|
||||
"""
|
||||
This will be called to perform the actual import. This is called inside the actual database transaction and the
|
||||
input object ``order`` has already been saved to the database.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class EmailColumn(ImportColumn):
|
||||
identifier = 'email'
|
||||
verbose_name = gettext_lazy('E-mail address')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
EmailValidator()(value)
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
order.email = value
|
||||
|
||||
|
||||
class SubeventColumn(ImportColumn):
|
||||
identifier = 'subevent'
|
||||
verbose_name = pgettext_lazy('subevents', 'Date')
|
||||
default_value = None
|
||||
|
||||
@cached_property
|
||||
def subevents(self):
|
||||
return list(self.event.subevents.filter(active=True).order_by('date_from'))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(p.pk), str(p)) for p in self.subevents
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
raise ValidationError(pgettext("subevent", "You need to select a date."))
|
||||
matches = [
|
||||
p for p in self.subevents
|
||||
if str(p.pk) == value or any(
|
||||
(v and v == value) for v in i18n_flat(p.name)) or p.date_from.isoformat() == value
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||
return matches[0]
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.subevent = value
|
||||
|
||||
|
||||
def i18n_flat(l):
|
||||
if isinstance(l.data, dict):
|
||||
return l.data.values()
|
||||
return [l.data]
|
||||
|
||||
|
||||
class ItemColumn(ImportColumn):
|
||||
identifier = 'item'
|
||||
verbose_name = gettext_lazy('Product')
|
||||
default_value = None
|
||||
|
||||
@cached_property
|
||||
def items(self):
|
||||
return list(self.event.items.filter(active=True))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(p.pk), str(p)) for p in self.items
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
matches = [
|
||||
p for p in self.items
|
||||
if str(p.pk) == value or (p.internal_name and p.internal_name == value) or any(
|
||||
(v and v == value) for v in i18n_flat(p.name))
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(_("No matching product was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(_("Multiple matching products were found."))
|
||||
return matches[0]
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.item = value
|
||||
|
||||
|
||||
class Variation(ImportColumn):
|
||||
identifier = 'variation'
|
||||
verbose_name = gettext_lazy('Product variation')
|
||||
|
||||
@cached_property
|
||||
def items(self):
|
||||
return list(ItemVariation.objects.filter(
|
||||
active=True, item__active=True, item__event=self.event
|
||||
).select_related('item'))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(p.pk), '{} – {}'.format(p.item, p.value)) for p in self.items
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
matches = [
|
||||
p for p in self.items
|
||||
if str(p.pk) == value or any((v and v == value) for v in i18n_flat(p.value)) and p.item_id == previous_values['item'].pk
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(_("No matching variation was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(_("Multiple matching variations were found."))
|
||||
return matches[0]
|
||||
elif previous_values['item'].variations.exists():
|
||||
raise ValidationError(_("You need to select a variation for this product."))
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.variation = value
|
||||
|
||||
|
||||
class InvoiceAddressCompany(ImportColumn):
|
||||
identifier = 'invoice_address_company'
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Invoice address') + ': ' + _('Company')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
invoice_address.company = value or ''
|
||||
invoice_address.is_business = bool(value)
|
||||
|
||||
|
||||
class InvoiceAddressNamePart(ImportColumn):
|
||||
def __init__(self, event, key, label):
|
||||
self.key = key
|
||||
self.label = label
|
||||
super().__init__(event)
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Invoice address') + ': ' + str(self.label)
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return 'invoice_address_name_{}'.format(self.key)
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
invoice_address.name_parts[self.key] = value or ''
|
||||
|
||||
|
||||
class InvoiceAddressStreet(ImportColumn):
|
||||
identifier = 'invoice_address_street'
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Invoice address') + ': ' + _('Address')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
invoice_address.address = value or ''
|
||||
|
||||
|
||||
class InvoiceAddressZip(ImportColumn):
|
||||
identifier = 'invoice_address_zipcode'
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Invoice address') + ': ' + _('ZIP code')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
invoice_address.zipcode = value or ''
|
||||
|
||||
|
||||
class InvoiceAddressCity(ImportColumn):
|
||||
identifier = 'invoice_address_city'
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Invoice address') + ': ' + _('City')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
invoice_address.city = value or ''
|
||||
|
||||
|
||||
class InvoiceAddressCountry(ImportColumn):
|
||||
identifier = 'invoice_address_country'
|
||||
default_value = None
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
return 'static:' + str(guess_country(self.event))
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Invoice address') + ': ' + _('Country')
|
||||
|
||||
def static_choices(self):
|
||||
return list(countries)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value and not Country(value).numeric:
|
||||
raise ValidationError(_("Please enter a valid country code."))
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
invoice_address.country = value or ''
|
||||
|
||||
|
||||
class InvoiceAddressState(ImportColumn):
|
||||
identifier = 'invoice_address_state'
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Invoice address') + ': ' + _('State')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
if previous_values.get('invoice_address_country') not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
raise ValidationError(_("States are not supported for this country."))
|
||||
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[previous_values.get('invoice_address_country')]
|
||||
match = [
|
||||
s for s in pycountry.subdivisions.get(country_code=previous_values.get('invoice_address_country'))
|
||||
if s.type in types and (s.code[3:] == value or s.name == value)
|
||||
]
|
||||
if len(match) == 0:
|
||||
raise ValidationError(_("Please enter a valid state."))
|
||||
return match[0].code[3:]
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
invoice_address.state = value or ''
|
||||
|
||||
|
||||
class InvoiceAddressVATID(ImportColumn):
|
||||
identifier = 'invoice_address_vat_id'
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Invoice address') + ': ' + _('VAT ID')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
invoice_address.vat_id = value or ''
|
||||
|
||||
|
||||
class InvoiceAddressReference(ImportColumn):
|
||||
identifier = 'invoice_address_internal_reference'
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Invoice address') + ': ' + _('Internal reference')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
invoice_address.internal_reference = value or ''
|
||||
|
||||
|
||||
class AttendeeNamePart(ImportColumn):
|
||||
def __init__(self, event, key, label):
|
||||
self.key = key
|
||||
self.label = label
|
||||
super().__init__(event)
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Attendee name') + ': ' + str(self.label)
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return 'attendee_name_{}'.format(self.key)
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.attendee_name_parts[self.key] = value or ''
|
||||
|
||||
|
||||
class AttendeeEmail(ImportColumn):
|
||||
identifier = 'attendee_email'
|
||||
verbose_name = gettext_lazy('Attendee e-mail address')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
EmailValidator()(value)
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.attendee_email = value
|
||||
|
||||
|
||||
class Price(ImportColumn):
|
||||
identifier = 'price'
|
||||
verbose_name = gettext_lazy('Price')
|
||||
default_label = gettext_lazy('Calculate from product')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value not in (None, ''):
|
||||
value = formats.sanitize_separators(re.sub(r'[^0-9.,-]', '', value))
|
||||
try:
|
||||
value = Decimal(value)
|
||||
except (DecimalException, TypeError):
|
||||
raise ValidationError(_('You entered an invalid number.'))
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
if value is None:
|
||||
p = get_price(position.item, position.variation, position.voucher, subevent=position.subevent,
|
||||
invoice_address=invoice_address)
|
||||
else:
|
||||
p = get_price(position.item, position.variation, position.voucher, subevent=position.subevent,
|
||||
invoice_address=invoice_address, custom_price=value, force_custom_price=True)
|
||||
position.price = p.gross
|
||||
position.tax_rule = position.item.tax_rule
|
||||
position.tax_rate = p.rate
|
||||
position.tax_value = p.tax
|
||||
|
||||
|
||||
class Secret(ImportColumn):
|
||||
identifier = 'secret'
|
||||
verbose_name = gettext_lazy('Ticket code')
|
||||
default_label = gettext_lazy('Generate automatically')
|
||||
|
||||
def __init__(self, *args):
|
||||
self._cached = set()
|
||||
super().__init__(*args)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value and (value in self._cached or OrderPosition.all.filter(order__event=self.event, secret=value).exists()):
|
||||
raise ValidationError(
|
||||
_('You cannot assign a position secret that already exists.')
|
||||
)
|
||||
self._cached.add(value)
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
if value:
|
||||
position.secret = value
|
||||
|
||||
|
||||
class Locale(ImportColumn):
|
||||
identifier = 'locale'
|
||||
verbose_name = gettext_lazy('Order locale')
|
||||
default_value = None
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
return 'static:' + self.event.settings.locale
|
||||
|
||||
def static_choices(self):
|
||||
locale_names = dict(settings.LANGUAGES)
|
||||
return [
|
||||
(a, locale_names[a]) for a in self.event.settings.locales
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
value = self.event.settings.locale
|
||||
if value not in self.event.settings.locales:
|
||||
raise ValidationError(_("Please enter a valid language code."))
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
order.locale = value
|
||||
|
||||
|
||||
class Saleschannel(ImportColumn):
|
||||
identifier = 'sales_channel'
|
||||
verbose_name = gettext_lazy('Sales channel')
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(sc.identifier, sc.verbose_name) for sc in get_all_sales_channels().values()
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
value = 'web'
|
||||
if value not in get_all_sales_channels():
|
||||
raise ValidationError(_("Please enter a valid sales channel."))
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
order.sales_channel = value
|
||||
|
||||
|
||||
class SeatColumn(ImportColumn):
|
||||
identifier = 'seat'
|
||||
verbose_name = gettext_lazy('Seat ID')
|
||||
|
||||
def __init__(self, *args):
|
||||
self._cached = set()
|
||||
super().__init__(*args)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
try:
|
||||
value = Seat.objects.get(
|
||||
seat_guid=value,
|
||||
subevent=previous_values.get('subevent')
|
||||
)
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError(_('No matching seat was found.'))
|
||||
if not value.is_available() or value in self._cached:
|
||||
raise ValidationError(
|
||||
_('The seat you selected has already been taken. Please select a different seat.'))
|
||||
self._cached.add(value)
|
||||
elif previous_values['item'].seat_category_mappings.filter(subevent=previous_values.get('subevent')).exists():
|
||||
raise ValidationError(_('You need to select a specific seat.'))
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.seat = value
|
||||
|
||||
|
||||
class Comment(ImportColumn):
|
||||
identifier = 'comment'
|
||||
verbose_name = gettext_lazy('Comment')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
order.comment = value or ''
|
||||
|
||||
|
||||
class QuestionColumn(ImportColumn):
|
||||
def __init__(self, event, q):
|
||||
self.q = q
|
||||
super().__init__(event)
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Question') + ': ' + str(self.q.question)
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return 'question_{}'.format(self.q.pk)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
return self.q.clean_answer(value)
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
if value:
|
||||
if not hasattr(order, '_answers'):
|
||||
order._answers = []
|
||||
if isinstance(value, QuestionOption):
|
||||
a = QuestionAnswer(orderposition=position, question=self.q, answer=str(value))
|
||||
a._options = [value]
|
||||
order._answers.append(a)
|
||||
elif isinstance(value, list):
|
||||
a = QuestionAnswer(orderposition=position, question=self.q, answer=', '.join(str(v) for v in value))
|
||||
a._options = value
|
||||
order._answers.append(a)
|
||||
else:
|
||||
order._answers.append(QuestionAnswer(question=self.q, answer=str(value), orderposition=position))
|
||||
|
||||
def save(self, order):
|
||||
for a in getattr(order, '_answers', []):
|
||||
a.orderposition = a.orderposition # This is apparently required after save() again
|
||||
a.save()
|
||||
if hasattr(a, '_options'):
|
||||
a.options.add(*a._options)
|
||||
|
||||
|
||||
def get_all_columns(event):
|
||||
default = []
|
||||
if event.has_subevents:
|
||||
default.append(SubeventColumn(event))
|
||||
default += [
|
||||
EmailColumn(event),
|
||||
ItemColumn(event),
|
||||
Variation(event),
|
||||
InvoiceAddressCompany(event),
|
||||
]
|
||||
scheme = PERSON_NAME_SCHEMES.get(event.settings.name_scheme)
|
||||
for n, l, w in scheme['fields']:
|
||||
default.append(InvoiceAddressNamePart(event, n, l))
|
||||
default += [
|
||||
InvoiceAddressStreet(event),
|
||||
InvoiceAddressZip(event),
|
||||
InvoiceAddressCity(event),
|
||||
InvoiceAddressCountry(event),
|
||||
InvoiceAddressState(event),
|
||||
InvoiceAddressVATID(event),
|
||||
InvoiceAddressReference(event),
|
||||
]
|
||||
for n, l, w in scheme['fields']:
|
||||
default.append(AttendeeNamePart(event, n, l))
|
||||
default += [
|
||||
AttendeeEmail(event),
|
||||
Price(event),
|
||||
Secret(event),
|
||||
Locale(event),
|
||||
Saleschannel(event),
|
||||
SeatColumn(event),
|
||||
Comment(event)
|
||||
]
|
||||
for q in event.questions.exclude(type='F'):
|
||||
default.append(QuestionColumn(event, q))
|
||||
|
||||
for recv, resp in order_import_columns.send(sender=event):
|
||||
default += resp
|
||||
|
||||
return default
|
||||
@@ -7,7 +7,9 @@ from typing import Any, Dict, Union
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import transaction
|
||||
from django.dispatch import receiver
|
||||
from django.forms import Form
|
||||
from django.http import HttpRequest
|
||||
@@ -18,10 +20,11 @@ from django_countries import Countries
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.forms import PlaceholderValidator
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, InvoiceAddress, Order, OrderPayment, OrderRefund,
|
||||
Quota,
|
||||
CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
|
||||
OrderRefund, Quota,
|
||||
)
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
@@ -29,7 +32,8 @@ from pretix.base.signals import register_payment_providers
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.helpers.money import DecimalTextInput
|
||||
from pretix.presale.views import get_cart_total
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
from pretix.presale.views import get_cart, get_cart_total
|
||||
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -59,12 +63,11 @@ class BasePaymentProvider:
|
||||
def __str__(self):
|
||||
return self.identifier
|
||||
|
||||
@property
|
||||
def is_implicit(self) -> bool:
|
||||
def is_implicit(self, request: HttpRequest) -> bool:
|
||||
"""
|
||||
Returns whether or whether not this payment provider is an "implicit" payment provider that will
|
||||
*always* and unconditionally be used if is_allowed() returns True and does not require any input.
|
||||
This is intended to be used by the FreePaymentProvider, which skips the payment choice page.
|
||||
This is intended to be used by the FreeOrderProvider, which skips the payment choice page.
|
||||
By default, this returns ``False``. Please do not set this if you don't know exactly what you are doing.
|
||||
"""
|
||||
return False
|
||||
@@ -80,6 +83,14 @@ class BasePaymentProvider:
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
"""
|
||||
Returns a priority that is used for sorting payment providers. Higher priority means higher up in the list.
|
||||
Default to 100. Providers with same priority are sorted alphabetically.
|
||||
"""
|
||||
return 100
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""
|
||||
@@ -275,8 +286,21 @@ class BasePaymentProvider:
|
||||
required=False,
|
||||
disabled=not self.event.settings.invoice_address_required
|
||||
)),
|
||||
('_restrict_to_sales_channels',
|
||||
forms.MultipleChoiceField(
|
||||
label=_('Restrict to specific sales channels'),
|
||||
choices=(
|
||||
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
|
||||
if c.payment_restrictions_supported
|
||||
),
|
||||
initial=['web'],
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
help_text=_(
|
||||
'Only allow the usage of this payment provider in the following sales channels'),
|
||||
))
|
||||
])
|
||||
d['_restricted_countries']._as_type = list
|
||||
d['_restrict_to_sales_channels']._as_type = list
|
||||
return d
|
||||
|
||||
def settings_form_clean(self, cleaned_data):
|
||||
@@ -388,7 +412,7 @@ class BasePaymentProvider:
|
||||
|
||||
The default implementation checks for the _availability_date setting to be either unset or in the future
|
||||
and for the _total_max and _total_min requirements to be met. It also checks the ``_restrict_countries``
|
||||
setting.
|
||||
and ``_restrict_to_sales_channels`` setting.
|
||||
|
||||
:param total: The total value without the payment method fee, after taxes.
|
||||
|
||||
@@ -429,6 +453,10 @@ class BasePaymentProvider:
|
||||
if str(ia.country) not in restricted_countries:
|
||||
return False
|
||||
|
||||
if hasattr(request, 'sales_channel') and request.sales_channel.identifier not in \
|
||||
self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']):
|
||||
return False
|
||||
|
||||
return timing and pricing
|
||||
|
||||
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
|
||||
@@ -584,6 +612,9 @@ class BasePaymentProvider:
|
||||
if str(ia.country) not in restricted_countries:
|
||||
return False
|
||||
|
||||
if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']):
|
||||
return False
|
||||
|
||||
return self._is_still_available(order=order)
|
||||
|
||||
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]:
|
||||
@@ -620,6 +651,19 @@ class BasePaymentProvider:
|
||||
"""
|
||||
return ''
|
||||
|
||||
def refund_control_render(self, request: HttpRequest, refund: OrderRefund) -> str:
|
||||
"""
|
||||
Will be called if the *event administrator* views the details of a refund.
|
||||
|
||||
It should return HTML code containing information regarding the current refund
|
||||
status and, if applicable, next steps.
|
||||
|
||||
The default implementation returns an empty string.
|
||||
|
||||
:param order: The order object
|
||||
"""
|
||||
return ''
|
||||
|
||||
def payment_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
"""
|
||||
Will be called to check if the provider supports automatic refunding for this
|
||||
@@ -634,6 +678,17 @@ class BasePaymentProvider:
|
||||
"""
|
||||
return False
|
||||
|
||||
def cancel_payment(self, payment: OrderPayment):
|
||||
"""
|
||||
Will be called to cancel a payment. The default implementation just sets the payment state to canceled,
|
||||
but in some cases you might want to notify an external provider.
|
||||
|
||||
On success, you should set ``payment.state = OrderPayment.PAYMENT_STATE_CANCELED`` (or call the super method).
|
||||
On failure, you should raise a PaymentException.
|
||||
"""
|
||||
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
|
||||
payment.save()
|
||||
|
||||
def execute_refund(self, refund: OrderRefund):
|
||||
"""
|
||||
Will be called to execute an refund. Note that refunds have an amount property and can be partial.
|
||||
@@ -699,8 +754,9 @@ class FreeOrderProvider(BasePaymentProvider):
|
||||
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
|
||||
from .services.cart import get_fees
|
||||
|
||||
cart = get_cart(request)
|
||||
total = get_cart_total(request)
|
||||
total += sum([f.value for f in get_fees(self.event, request, total, None, None)])
|
||||
total += sum([f.value for f in get_fees(self.event, request, total, None, None, cart)])
|
||||
return total == 0
|
||||
|
||||
def order_change_allowed(self, order: Order) -> bool:
|
||||
@@ -761,8 +817,7 @@ class ManualPayment(BasePaymentProvider):
|
||||
return _('In test mode, you can just manually mark this order as paid in the backend after it has been '
|
||||
'created.')
|
||||
|
||||
@property
|
||||
def is_implicit(self):
|
||||
def is_implicit(self, request: HttpRequest):
|
||||
return 'pretix.plugins.manualpayment' not in self.event.plugins
|
||||
|
||||
def is_allowed(self, request: HttpRequest, total: Decimal=None):
|
||||
@@ -867,7 +922,10 @@ class OffsettingProvider(BasePaymentProvider):
|
||||
provider='offsetting',
|
||||
info=json.dumps({'orders': [refund.order.code]})
|
||||
)
|
||||
p.confirm()
|
||||
try:
|
||||
p.confirm(ignore_date=True)
|
||||
except Quota.QuotaExceededException:
|
||||
pass
|
||||
|
||||
@property
|
||||
def settings_form_fields(self) -> dict:
|
||||
@@ -888,6 +946,222 @@ class OffsettingProvider(BasePaymentProvider):
|
||||
return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders']))
|
||||
|
||||
|
||||
class GiftCardPayment(BasePaymentProvider):
|
||||
identifier = "giftcard"
|
||||
verbose_name = _("Gift card")
|
||||
priority = 10
|
||||
|
||||
@property
|
||||
def settings_form_fields(self):
|
||||
f = super().settings_form_fields
|
||||
del f['_fee_abs']
|
||||
del f['_fee_percent']
|
||||
del f['_fee_reverse_calc']
|
||||
del f['_total_min']
|
||||
del f['_total_max']
|
||||
del f['_invoice_text']
|
||||
return f
|
||||
|
||||
@property
|
||||
def test_mode_message(self) -> str:
|
||||
return _("In test mode, only test cards will work.")
|
||||
|
||||
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
|
||||
return super().is_allowed(request, total) and self.event.organizer.has_gift_cards
|
||||
|
||||
def order_change_allowed(self, order: Order) -> bool:
|
||||
return super().order_change_allowed(order) and self.event.organizer.has_gift_cards
|
||||
|
||||
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
|
||||
return get_template('pretixcontrol/giftcards/checkout.html').render({})
|
||||
|
||||
def checkout_confirm_render(self, request) -> str:
|
||||
return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({})
|
||||
|
||||
def refund_control_render(self, request, refund) -> str:
|
||||
from .models import GiftCard
|
||||
|
||||
if 'gift_card' in refund.info_data:
|
||||
gc = GiftCard.objects.get(pk=refund.info_data.get('gift_card'))
|
||||
template = get_template('pretixcontrol/giftcards/payment.html')
|
||||
|
||||
ctx = {
|
||||
'request': request,
|
||||
'event': self.event,
|
||||
'gc': gc,
|
||||
}
|
||||
return template.render(ctx)
|
||||
|
||||
def payment_control_render(self, request, payment) -> str:
|
||||
from .models import GiftCard
|
||||
|
||||
if 'gift_card' in payment.info_data:
|
||||
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
|
||||
template = get_template('pretixcontrol/giftcards/payment.html')
|
||||
|
||||
ctx = {
|
||||
'request': request,
|
||||
'event': self.event,
|
||||
'gc': gc,
|
||||
}
|
||||
return template.render(ctx)
|
||||
|
||||
def api_payment_details(self, payment: OrderPayment):
|
||||
from .models import GiftCard
|
||||
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
|
||||
return {
|
||||
'gift_card': {
|
||||
'id': gc.pk,
|
||||
'secret': gc.secret,
|
||||
'organizer': gc.issuer.slug
|
||||
}
|
||||
}
|
||||
|
||||
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
return True
|
||||
|
||||
def payment_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
return True
|
||||
|
||||
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]:
|
||||
for p in get_cart(request):
|
||||
if p.item.issue_giftcard:
|
||||
messages.error(request, _("You cannot pay with gift cards when buying a gift card."))
|
||||
return
|
||||
|
||||
cs = cart_session(request)
|
||||
try:
|
||||
gc = self.event.organizer.accepted_gift_cards.get(
|
||||
secret=request.POST.get("giftcard")
|
||||
)
|
||||
if gc.currency != self.event.currency:
|
||||
messages.error(request, _("This gift card does not support this currency."))
|
||||
return
|
||||
if gc.testmode and not self.event.testmode:
|
||||
messages.error(request, _("This gift card can only be used in test mode."))
|
||||
return
|
||||
if not gc.testmode and self.event.testmode:
|
||||
messages.error(request, _("Only test gift cards can be used in test mode."))
|
||||
return
|
||||
if gc.value <= Decimal("0.00"):
|
||||
messages.error(request, _("All credit on this gift card has been used."))
|
||||
return
|
||||
if 'gift_cards' not in cs:
|
||||
cs['gift_cards'] = []
|
||||
elif gc.pk in cs['gift_cards']:
|
||||
messages.error(request, _("This gift card is already used for your payment."))
|
||||
return
|
||||
cs['gift_cards'] = cs['gift_cards'] + [gc.pk]
|
||||
|
||||
remainder = cart['total'] - gc.value
|
||||
if remainder >= Decimal('0.00'):
|
||||
del cs['payment']
|
||||
messages.success(request, _("Your gift card has been applied, but {} still need to be paid. Please select a payment method.").format(
|
||||
money_filter(remainder, self.event.currency)
|
||||
))
|
||||
else:
|
||||
messages.success(request, _("Your gift card has been applied."))
|
||||
|
||||
kwargs = {'step': 'payment'}
|
||||
if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs:
|
||||
kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace']
|
||||
return eventreverse(self.event, 'presale:event.checkout', kwargs=kwargs)
|
||||
except GiftCard.DoesNotExist:
|
||||
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists():
|
||||
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
|
||||
"the product selection."))
|
||||
else:
|
||||
messages.error(request, _("This gift card is not known."))
|
||||
except GiftCard.MultipleObjectsReturned:
|
||||
messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event."))
|
||||
|
||||
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str, None]:
|
||||
for p in payment.order.positions.all():
|
||||
if p.item.issue_giftcard:
|
||||
messages.error(request, _("You cannot pay with gift cards when buying a gift card."))
|
||||
return
|
||||
|
||||
try:
|
||||
gc = self.event.organizer.accepted_gift_cards.get(
|
||||
secret=request.POST.get("giftcard")
|
||||
)
|
||||
if gc.currency != self.event.currency:
|
||||
messages.error(request, _("This gift card does not support this currency."))
|
||||
return
|
||||
if gc.testmode and not payment.order.testmode:
|
||||
messages.error(request, _("This gift card can only be used in test mode."))
|
||||
return
|
||||
if not gc.testmode and payment.order.testmode:
|
||||
messages.error(request, _("Only test gift cards can be used in test mode."))
|
||||
return
|
||||
if gc.value <= Decimal("0.00"):
|
||||
messages.error(request, _("All credit on this gift card has been used."))
|
||||
return
|
||||
payment.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'retry': True
|
||||
}
|
||||
payment.amount = min(payment.amount, gc.value)
|
||||
payment.save()
|
||||
|
||||
return True
|
||||
except GiftCard.DoesNotExist:
|
||||
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists():
|
||||
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
|
||||
"the product selection."))
|
||||
else:
|
||||
messages.error(request, _("This gift card is not known."))
|
||||
except GiftCard.MultipleObjectsReturned:
|
||||
messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event."))
|
||||
|
||||
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
|
||||
# This method will only be called when retrying payments, e.g. after a payment_prepare call. It is not called
|
||||
# during the order creation phase because this payment provider is a special case.
|
||||
for p in payment.order.positions.all(): # noqa - just a safeguard
|
||||
if p.item.issue_giftcard:
|
||||
raise PaymentException(_("You cannot pay with gift cards when buying a gift card."))
|
||||
|
||||
gcpk = payment.info_data.get('gift_card')
|
||||
if not gcpk or not payment.info_data.get('retry'):
|
||||
raise PaymentException("Invalid state, should never occur.")
|
||||
with transaction.atomic():
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gcpk)
|
||||
if gc.currency != self.event.currency: # noqa - just a safeguard
|
||||
raise PaymentException(_("This gift card does not support this currency."))
|
||||
if not gc.accepted_by(self.event.organizer): # noqa - just a safeguard
|
||||
raise PaymentException(_("This gift card is not accepted by this event organizer."))
|
||||
if payment.amount > gc.value: # noqa - just a safeguard
|
||||
raise PaymentException(_("This gift card was used in the meantime. Please try again"))
|
||||
trans = gc.transactions.create(
|
||||
value=-1 * payment.amount,
|
||||
order=payment.order,
|
||||
payment=payment
|
||||
)
|
||||
payment.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'transaction_id': trans.pk,
|
||||
}
|
||||
payment.confirm()
|
||||
|
||||
def payment_is_valid_session(self, request: HttpRequest) -> bool:
|
||||
return True
|
||||
|
||||
@transaction.atomic()
|
||||
def execute_refund(self, refund: OrderRefund):
|
||||
from .models import GiftCard
|
||||
gc = GiftCard.objects.get(pk=refund.info_data.get('gift_card') or refund.payment.info_data.get('gift_card'))
|
||||
trans = gc.transactions.create(
|
||||
value=refund.amount,
|
||||
order=refund.order,
|
||||
refund=refund
|
||||
)
|
||||
refund.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'transaction_id': trans.pk,
|
||||
}
|
||||
refund.done()
|
||||
|
||||
|
||||
@receiver(register_payment_providers, dispatch_uid="payment_free")
|
||||
def register_payment_provider(sender, **kwargs):
|
||||
return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment]
|
||||
return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment, GiftCardPayment]
|
||||
|
||||
@@ -10,10 +10,13 @@ from functools import partial
|
||||
from io import BytesIO
|
||||
|
||||
import bleach
|
||||
from arabic_reshaper import ArabicReshaper
|
||||
from bidi.algorithm import get_display
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from PyPDF2 import PdfFileReader
|
||||
@@ -31,6 +34,7 @@ from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.platypus import Paragraph
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.invoice import ThumbnailingImageReader
|
||||
from pretix.base.models import Order, OrderPosition
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
@@ -52,35 +56,40 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": "A1B2C",
|
||||
"evaluate": lambda orderposition, order, event: orderposition.order.code
|
||||
}),
|
||||
("positionid", {
|
||||
"label": _("Order position number"),
|
||||
"editor_sample": "1",
|
||||
"evaluate": lambda orderposition, order, event: str(orderposition.positionid)
|
||||
}),
|
||||
("item", {
|
||||
"label": _("Product name"),
|
||||
"editor_sample": _("Sample product"),
|
||||
"evaluate": lambda orderposition, order, event: str(orderposition.item.name)
|
||||
"evaluate": lambda orderposition, order, event: escape(str(orderposition.item.name))
|
||||
}),
|
||||
("variation", {
|
||||
"label": _("Variation name"),
|
||||
"editor_sample": _("Sample variation"),
|
||||
"evaluate": lambda op, order, event: str(op.variation) if op.variation else ''
|
||||
"evaluate": lambda op, order, event: escape(str(op.variation) if op.variation else '')
|
||||
}),
|
||||
("item_description", {
|
||||
"label": _("Product description"),
|
||||
"editor_sample": _("Sample product description"),
|
||||
"evaluate": lambda orderposition, order, event: str(orderposition.item.description)
|
||||
"evaluate": lambda orderposition, order, event: escape(str(orderposition.item.description))
|
||||
}),
|
||||
("itemvar", {
|
||||
"label": _("Product name and variation"),
|
||||
"editor_sample": _("Sample product – sample variation"),
|
||||
"evaluate": lambda orderposition, order, event: (
|
||||
"evaluate": lambda orderposition, order, event: escape((
|
||||
'{} - {}'.format(orderposition.item.name, orderposition.variation)
|
||||
if orderposition.variation else str(orderposition.item.name)
|
||||
)
|
||||
))
|
||||
}),
|
||||
("item_category", {
|
||||
"label": _("Product category"),
|
||||
"editor_sample": _("Ticket category"),
|
||||
"evaluate": lambda orderposition, order, event: (
|
||||
"evaluate": lambda orderposition, order, event: escape((
|
||||
str(orderposition.item.category.name) if orderposition.item.category else ""
|
||||
)
|
||||
))
|
||||
}),
|
||||
("price", {
|
||||
"label": _("Price"),
|
||||
@@ -99,12 +108,12 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
("attendee_name", {
|
||||
"label": _("Attendee name"),
|
||||
"editor_sample": _("John Doe"),
|
||||
"evaluate": lambda op, order, ev: op.attendee_name or (op.addon_to.attendee_name if op.addon_to else '')
|
||||
"evaluate": lambda op, order, ev: escape(op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''))
|
||||
}),
|
||||
("event_name", {
|
||||
"label": _("Event name"),
|
||||
"editor_sample": _("Sample event name"),
|
||||
"evaluate": lambda op, order, ev: str(ev.name)
|
||||
"evaluate": lambda op, order, ev: escape(str(ev.name))
|
||||
}),
|
||||
("event_date", {
|
||||
"label": _("Event date"),
|
||||
@@ -185,12 +194,17 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
("invoice_name", {
|
||||
"label": _("Invoice address name"),
|
||||
"editor_sample": _("John Doe"),
|
||||
"evaluate": lambda op, order, ev: order.invoice_address.name if getattr(order, 'invoice_address', None) else ''
|
||||
"evaluate": lambda op, order, ev: escape(order.invoice_address.name if getattr(order, 'invoice_address', None) else '')
|
||||
}),
|
||||
("invoice_company", {
|
||||
"label": _("Invoice address company"),
|
||||
"editor_sample": _("Sample company"),
|
||||
"evaluate": lambda op, order, ev: order.invoice_address.company if getattr(order, 'invoice_address', None) else ''
|
||||
"evaluate": lambda op, order, ev: escape(order.invoice_address.company if getattr(order, 'invoice_address', None) else '')
|
||||
}),
|
||||
("invoice_city", {
|
||||
"label": _("Invoice address city"),
|
||||
"editor_sample": _("Sample city"),
|
||||
"evaluate": lambda op, order, ev: escape(order.invoice_address.city if getattr(order, 'invoice_address', None) else '')
|
||||
}),
|
||||
("addons", {
|
||||
"label": _("List of Add-Ons"),
|
||||
@@ -207,7 +221,7 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
("organizer", {
|
||||
"label": _("Organizer name"),
|
||||
"editor_sample": _("Event organizer company"),
|
||||
"evaluate": lambda op, order, ev: str(order.event.organizer.name)
|
||||
"evaluate": lambda op, order, ev: escape(str(order.event.organizer.name))
|
||||
}),
|
||||
("organizer_info_text", {
|
||||
"label": _("Organizer info text"),
|
||||
@@ -287,7 +301,7 @@ def variables_from_questions(sender, *args, **kwargs):
|
||||
if not a:
|
||||
return ""
|
||||
else:
|
||||
return str(a).replace("\n", "<br/>\n")
|
||||
return escape(str(a)).replace("\n", "<br/>\n")
|
||||
|
||||
d = {}
|
||||
for q in sender.questions.all():
|
||||
@@ -300,11 +314,11 @@ def variables_from_questions(sender, *args, **kwargs):
|
||||
|
||||
|
||||
def _get_attendee_name_part(key, op, order, ev):
|
||||
return op.attendee_name_parts.get(key, '')
|
||||
return escape(op.attendee_name_parts.get(key, ''))
|
||||
|
||||
|
||||
def _get_ia_name_part(key, op, order, ev):
|
||||
return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
|
||||
return escape(order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else '')
|
||||
|
||||
|
||||
def get_variables(event):
|
||||
@@ -399,7 +413,11 @@ class Renderer:
|
||||
def _get_ev(self, op, order):
|
||||
return op.subevent or order.event
|
||||
|
||||
def _get_text_content(self, op: OrderPosition, order: Order, o: dict):
|
||||
def _get_text_content(self, op: OrderPosition, order: Order, o: dict, inner=False):
|
||||
if o.get('locale', None) and not inner:
|
||||
with language(o['locale']):
|
||||
return self._get_text_content(op, order, o, True)
|
||||
|
||||
ev = self._get_ev(op, order)
|
||||
if not o['content']:
|
||||
return '(error)'
|
||||
@@ -443,11 +461,32 @@ class Renderer:
|
||||
tags=["br"], attributes={}, styles=[], strip=True
|
||||
)
|
||||
)
|
||||
|
||||
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
|
||||
# to resolve all ligatures and python-bidi to switch RTL texts.
|
||||
configuration = {
|
||||
'delete_harakat': True,
|
||||
'support_ligatures': False,
|
||||
}
|
||||
reshaper = ArabicReshaper(configuration=configuration)
|
||||
text = "<br/>".join(get_display(reshaper.reshape(l)) for l in text.split("<br/>"))
|
||||
|
||||
p = Paragraph(text, style=style)
|
||||
p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
|
||||
w, h = p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
|
||||
# p_size = p.wrap(float(o['width']) * mm, 1000 * mm)
|
||||
ad = getAscentDescent(font, float(o['fontsize']))
|
||||
p.drawOn(canvas, float(o['left']) * mm, float(o['bottom']) * mm - ad[1])
|
||||
canvas.saveState()
|
||||
# The ascent/descent offsets here are not really proven to be correct, they're just empirical values to get
|
||||
# reportlab render similarly to browser canvas.
|
||||
if o.get('downward', False):
|
||||
canvas.translate(float(o['left']) * mm, float(o['bottom']) * mm)
|
||||
canvas.rotate(o.get('rotation', 0) * -1)
|
||||
p.drawOn(canvas, 0, -h - ad[1] / 2)
|
||||
else:
|
||||
canvas.translate(float(o['left']) * mm, float(o['bottom']) * mm + h)
|
||||
canvas.rotate(o.get('rotation', 0) * -1)
|
||||
p.drawOn(canvas, 0, -h - ad[1])
|
||||
canvas.restoreState()
|
||||
|
||||
def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition):
|
||||
for o in self.layout:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import sys
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
@@ -44,13 +45,14 @@ def get_all_plugins(event=None) -> List[type]:
|
||||
|
||||
|
||||
class PluginConfig(AppConfig):
|
||||
IGNORE = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not hasattr(self, 'PretixPluginMeta'):
|
||||
raise ImproperlyConfigured("A pretix plugin config should have a PretixPluginMeta inner class.")
|
||||
|
||||
if hasattr(self.PretixPluginMeta, 'compatibility'):
|
||||
if hasattr(self.PretixPluginMeta, 'compatibility') and not os.environ.get("PRETIX_IGNORE_CONFLICTS") == "True":
|
||||
import pkg_resources
|
||||
try:
|
||||
pkg_resources.require(self.PretixPluginMeta.compatibility)
|
||||
|
||||
@@ -103,11 +103,18 @@ class RelativeDateWrapper:
|
||||
else:
|
||||
timeparts = parts[2].split(':')
|
||||
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
|
||||
data = RelativeDate(
|
||||
days_before=int(parts[1] or 0),
|
||||
base_date_name=parts[3],
|
||||
time=time
|
||||
)
|
||||
try:
|
||||
data = RelativeDate(
|
||||
days_before=int(parts[1] or 0),
|
||||
base_date_name=parts[3],
|
||||
time=time
|
||||
)
|
||||
except ValueError:
|
||||
data = RelativeDate(
|
||||
days_before=0,
|
||||
base_date_name=parts[3],
|
||||
time=time
|
||||
)
|
||||
else:
|
||||
data = parser.parse(input)
|
||||
return RelativeDateWrapper(data)
|
||||
|
||||
@@ -12,9 +12,10 @@ from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, Seat,
|
||||
CartPosition, Event, InvoiceAddress, Item, ItemVariation, Seat,
|
||||
SeatCategoryMapping, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
@@ -80,6 +81,10 @@ error_messages = {
|
||||
'cart if you want to use it for a different product.'),
|
||||
'voucher_expired': _('This voucher is expired.'),
|
||||
'voucher_invalid_item': _('This voucher is not valid for this product.'),
|
||||
'voucher_invalid_seat': _('This voucher is not valid for this seat.'),
|
||||
'voucher_no_match': _('We did not find any position in your cart that we could use this voucher for. If you want '
|
||||
'to add something new to your cart using that voucher, you can do so with the voucher '
|
||||
'redemption option on the bottom of the page.'),
|
||||
'voucher_item_not_available': _(
|
||||
'Your voucher is valid for a product that is currently not for sale.'),
|
||||
'voucher_invalid_subevent': pgettext_lazy('subevent', 'This voucher is not valid for this event date.'),
|
||||
@@ -97,17 +102,21 @@ error_messages = {
|
||||
'seat_forbidden': _('You can not select a seat for this position.'),
|
||||
'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'),
|
||||
'seat_multiple': _('You can not select the same seat multiple times.'),
|
||||
'gift_card': _("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."),
|
||||
}
|
||||
|
||||
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
|
||||
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat'))
|
||||
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat',
|
||||
'price_before_voucher'))
|
||||
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
||||
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price'))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
|
||||
'quotas', 'subevent', 'seat'))
|
||||
'quotas', 'subevent', 'seat', 'price_before_voucher'))
|
||||
order = {
|
||||
RemoveOperation: 10,
|
||||
VoucherOperation: 15,
|
||||
ExtendOperation: 20,
|
||||
AddOperation: 30
|
||||
}
|
||||
@@ -216,15 +225,16 @@ class CartManager:
|
||||
})
|
||||
|
||||
def _check_max_cart_size(self):
|
||||
cartsize = self.positions.filter(addon_to__isnull=True).count()
|
||||
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
|
||||
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
|
||||
not op.position.addon_to_id])
|
||||
if cartsize > int(self.event.settings.max_items_per_order):
|
||||
# TODO: i18n plurals
|
||||
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
|
||||
if not get_all_sales_channels()[self._sales_channel].unlimited_items_per_order:
|
||||
cartsize = self.positions.filter(addon_to__isnull=True).count()
|
||||
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
|
||||
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
|
||||
not op.position.addon_to_id])
|
||||
if cartsize > int(self.event.settings.max_items_per_order):
|
||||
# TODO: i18n plurals
|
||||
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
|
||||
|
||||
def _check_item_constraints(self, op):
|
||||
def _check_item_constraints(self, op, current_ops=[]):
|
||||
if isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
|
||||
if not (
|
||||
(isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or
|
||||
@@ -251,6 +261,9 @@ class CartManager:
|
||||
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
|
||||
raise CartError(error_messages['voucher_invalid_item'])
|
||||
|
||||
if op.voucher and op.voucher.seat and op.voucher.seat != op.seat:
|
||||
raise CartError(error_messages['voucher_invalid_seat'])
|
||||
|
||||
if op.voucher and op.voucher.subevent_id and op.voucher.subevent_id != op.subevent.pk:
|
||||
raise CartError(error_messages['voucher_invalid_subevent'])
|
||||
|
||||
@@ -264,7 +277,7 @@ class CartManager:
|
||||
raise CartError(error_messages['ended'])
|
||||
|
||||
seated = self._is_seated(op.item, op.subevent)
|
||||
if seated and (not op.seat or op.seat.blocked):
|
||||
if seated and (not op.seat or (op.seat.blocked and self._sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel)):
|
||||
raise CartError(error_messages['seat_invalid'])
|
||||
elif op.seat and not seated:
|
||||
raise CartError(error_messages['seat_forbidden'])
|
||||
@@ -293,10 +306,10 @@ class CartManager:
|
||||
if op.item.max_per_order or op.item.min_per_order:
|
||||
new_total = (
|
||||
len([1 for p in self.positions if p.item_id == op.item.pk]) +
|
||||
sum([_op.count for _op in self._operations
|
||||
sum([_op.count for _op in self._operations + current_ops
|
||||
if isinstance(_op, self.AddOperation) and _op.item == op.item]) +
|
||||
op.count -
|
||||
len([1 for _op in self._operations
|
||||
len([1 for _op in self._operations + current_ops
|
||||
if isinstance(_op, self.RemoveOperation) and _op.position.item_id == op.item.pk])
|
||||
)
|
||||
|
||||
@@ -357,10 +370,10 @@ class CartManager:
|
||||
cp.item.requires_seat = cp.requires_seat
|
||||
|
||||
if cp.is_bundled:
|
||||
try:
|
||||
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
|
||||
bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first()
|
||||
if bundle:
|
||||
price = bundle.designated_price or 0
|
||||
except ItemBundle.DoesNotExist:
|
||||
else:
|
||||
price = cp.price
|
||||
|
||||
changed_prices[cp.pk] = price
|
||||
@@ -372,6 +385,7 @@ class CartManager:
|
||||
else:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
|
||||
force_custom_price=True)
|
||||
pbv = TAXED_ZERO
|
||||
else:
|
||||
bundled_sum = Decimal('0.00')
|
||||
if not cp.addon_to_id:
|
||||
@@ -384,9 +398,14 @@ class CartManager:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||
cp_is_net=True, bundled_sum=bundled_sum)
|
||||
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
|
||||
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
|
||||
cp_is_net=True, bundled_sum=bundled_sum)
|
||||
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=0, tax=0, name='')
|
||||
else:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||
bundled_sum=bundled_sum)
|
||||
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
|
||||
bundled_sum=bundled_sum)
|
||||
|
||||
quotas = list(cp.quotas)
|
||||
if not quotas:
|
||||
@@ -402,7 +421,7 @@ class CartManager:
|
||||
|
||||
op = self.ExtendOperation(
|
||||
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
|
||||
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat
|
||||
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat, price_before_voucher=pbv
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
|
||||
@@ -412,6 +431,60 @@ class CartManager:
|
||||
self._operations.append(op)
|
||||
return err
|
||||
|
||||
def apply_voucher(self, voucher_code: str):
|
||||
if self._operations:
|
||||
raise CartError('Applying a voucher to the whole cart should not be combined with other operations.')
|
||||
try:
|
||||
voucher = self.event.vouchers.get(code__iexact=voucher_code.strip())
|
||||
except Voucher.DoesNotExist:
|
||||
raise CartError(error_messages['voucher_invalid'])
|
||||
voucher_use_diff = Counter()
|
||||
ops = []
|
||||
|
||||
if not voucher.is_active():
|
||||
raise CartError(error_messages['voucher_expired'])
|
||||
|
||||
for p in self.positions:
|
||||
if p.voucher_id:
|
||||
continue
|
||||
|
||||
if not voucher.applies_to(p.item, p.variation):
|
||||
continue
|
||||
|
||||
if voucher.seat and voucher.seat != p.seat:
|
||||
continue
|
||||
|
||||
if voucher.subevent_id and voucher.subevent_id != p.subevent_id:
|
||||
continue
|
||||
|
||||
if p.is_bundled:
|
||||
continue
|
||||
|
||||
bundled_sum = Decimal('0.00')
|
||||
if not p.addon_to_id:
|
||||
for bundledp in p.addons.all():
|
||||
if bundledp.is_bundled:
|
||||
bundledprice = bundledp.price
|
||||
bundled_sum += bundledprice
|
||||
|
||||
price = self._get_price(p.item, p.variation, voucher, None, p.subevent, bundled_sum=bundled_sum)
|
||||
"""
|
||||
if price.gross > p.price:
|
||||
continue
|
||||
"""
|
||||
|
||||
voucher_use_diff[voucher] += 1
|
||||
ops.append((p.price - price.gross, self.VoucherOperation(p, voucher, price)))
|
||||
|
||||
# If there are not enough voucher usages left for the full cart, let's apply them in the order that benefits
|
||||
# the user the most.
|
||||
ops.sort(key=lambda k: k[0], reverse=True)
|
||||
self._operations += [k[1] for k in ops]\
|
||||
|
||||
if not voucher_use_diff:
|
||||
raise CartError(error_messages['voucher_no_match'])
|
||||
self._voucher_use_diff += voucher_use_diff
|
||||
|
||||
def add_new_items(self, items: List[dict]):
|
||||
# Fetch items from the database
|
||||
self._update_items_cache([i['item'] for i in items], [i['variation'] for i in items])
|
||||
@@ -422,7 +495,7 @@ class CartManager:
|
||||
|
||||
for i in items:
|
||||
if self.event.has_subevents:
|
||||
if not i.get('subevent'):
|
||||
if not i.get('subevent') or int(i.get('subevent')) not in self._subevents_cache:
|
||||
raise CartError(error_messages['subevent_required'])
|
||||
subevent = self._subevents_cache[int(i.get('subevent'))]
|
||||
else:
|
||||
@@ -503,18 +576,20 @@ class CartManager:
|
||||
bop = self.AddOperation(
|
||||
count=bundle.count, item=bitem, variation=bvar, price=bprice,
|
||||
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
|
||||
includes_tax=bool(bprice.rate), bundled=[], seat=None
|
||||
includes_tax=bool(bprice.rate), bundled=[], seat=None, price_before_voucher=bprice,
|
||||
)
|
||||
self._check_item_constraints(bop)
|
||||
self._check_item_constraints(bop, operations)
|
||||
bundled.append(bop)
|
||||
|
||||
price = self._get_price(item, variation, voucher, i.get('price'), subevent, bundled_sum=bundled_sum)
|
||||
pbv = self._get_price(item, variation, None, i.get('price'), subevent, bundled_sum=bundled_sum)
|
||||
|
||||
op = self.AddOperation(
|
||||
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
|
||||
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat
|
||||
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat,
|
||||
price_before_voucher=pbv
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
self._check_item_constraints(op, operations)
|
||||
operations.append(op)
|
||||
|
||||
self._quota_diff.update(quota_diff)
|
||||
@@ -618,9 +693,10 @@ class CartManager:
|
||||
|
||||
op = self.AddOperation(
|
||||
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
|
||||
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None
|
||||
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None,
|
||||
price_before_voucher=None
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
self._check_item_constraints(op, operations)
|
||||
operations.append(op)
|
||||
|
||||
# Check constraints on the add-on combinations
|
||||
@@ -755,7 +831,7 @@ class CartManager:
|
||||
self._operations.sort(key=lambda a: self.order[type(a)])
|
||||
seats_seen = set()
|
||||
|
||||
for op in self._operations:
|
||||
for iop, op in enumerate(self._operations):
|
||||
if isinstance(op, self.RemoveOperation):
|
||||
if op.position.expires > self.now_dt:
|
||||
for q in op.position.quotas:
|
||||
@@ -823,7 +899,7 @@ class CartManager:
|
||||
available_count = 0
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
if op.seat and not op.seat.is_available():
|
||||
if op.seat and not op.seat.is_available(ignore_voucher_id=op.voucher.id if op.voucher else None, sales_channel=self._sales_channel):
|
||||
available_count = 0
|
||||
err = err or error_messages['seat_unavailable']
|
||||
|
||||
@@ -832,7 +908,8 @@ class CartManager:
|
||||
event=self.event, item=op.item, variation=op.variation,
|
||||
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
|
||||
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
|
||||
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat
|
||||
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat,
|
||||
price_before_voucher=op.price_before_voucher.gross if op.price_before_voucher is not None else None
|
||||
)
|
||||
if self.event.settings.attendee_names_asked:
|
||||
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
|
||||
@@ -871,13 +948,16 @@ class CartManager:
|
||||
|
||||
new_cart_positions.append(cp)
|
||||
elif isinstance(op, self.ExtendOperation):
|
||||
if op.seat and not op.seat.is_available(ignore_cart=op.position):
|
||||
if op.seat and not op.seat.is_available(ignore_cart=op.position, sales_channel=self._sales_channel,
|
||||
ignore_voucher_id=op.position.voucher_id):
|
||||
err = err or error_messages['seat_unavailable']
|
||||
op.position.addons.all().delete()
|
||||
op.position.delete()
|
||||
elif available_count == 1:
|
||||
op.position.expires = self._expiry
|
||||
op.position.price = op.price.gross
|
||||
if op.price_before_voucher is not None:
|
||||
op.position.price_before_voucher = op.price_before_voucher.gross
|
||||
try:
|
||||
op.position.save(force_update=True)
|
||||
except DatabaseError:
|
||||
@@ -888,6 +968,20 @@ class CartManager:
|
||||
op.position.delete()
|
||||
else:
|
||||
raise AssertionError("ExtendOperation cannot affect more than one item")
|
||||
elif isinstance(op, self.VoucherOperation):
|
||||
if vouchers_ok[op.voucher] < 1:
|
||||
if iop == 0:
|
||||
raise CartError(error_messages['voucher_redeemed'])
|
||||
else:
|
||||
# We fail silently if we could only apply the voucher to part of the cart, since that might
|
||||
# be expected
|
||||
continue
|
||||
|
||||
op.position.price_before_voucher = op.position.price
|
||||
op.position.price = op.price.gross
|
||||
op.position.voucher = op.voucher
|
||||
op.position.save()
|
||||
vouchers_ok[op.voucher] -= 1
|
||||
|
||||
for p in new_cart_positions:
|
||||
if getattr(p, '_answers', None):
|
||||
@@ -957,15 +1051,42 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
|
||||
return totaldiff
|
||||
|
||||
|
||||
def get_fees(event, request, total, invoice_address, provider):
|
||||
fees = []
|
||||
def get_fees(event, request, total, invoice_address, provider, positions):
|
||||
from pretix.presale.views.cart import cart_session
|
||||
|
||||
fees = []
|
||||
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
|
||||
total=total):
|
||||
total=total, positions=positions):
|
||||
if resp:
|
||||
fees += resp
|
||||
|
||||
total = total + sum(f.value for f in fees)
|
||||
|
||||
cs = cart_session(request)
|
||||
if cs.get('gift_cards'):
|
||||
gcs = cs['gift_cards']
|
||||
gc_qs = event.organizer.accepted_gift_cards.filter(pk__in=cs.get('gift_cards'), currency=event.currency)
|
||||
summed = 0
|
||||
for gc in gc_qs:
|
||||
if gc.testmode != event.testmode:
|
||||
gcs.remove(gc.pk)
|
||||
continue
|
||||
fval = Decimal(gc.value) # TODO: don't require an extra query
|
||||
fval = min(fval, total - summed)
|
||||
if fval > 0:
|
||||
total -= fval
|
||||
summed += fval
|
||||
fees.append(OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_GIFTCARD,
|
||||
internal_type='giftcard',
|
||||
description=gc.secret,
|
||||
value=-1 * fval,
|
||||
tax_rate=Decimal('0.00'),
|
||||
tax_value=Decimal('0.00'),
|
||||
tax_rule=TaxRule.zero()
|
||||
))
|
||||
cs['gift_cards'] = gcs
|
||||
|
||||
if provider and total != 0:
|
||||
provider = event.get_payment_providers().get(provider)
|
||||
if provider:
|
||||
@@ -1026,7 +1147,27 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en') -> None:
|
||||
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param voucher: A voucher code
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||
cm.apply_voucher(voucher)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
@@ -1036,7 +1177,7 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
|
||||
with language(locale):
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||
cm.remove_item(position)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
@@ -1046,7 +1187,7 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def clear_cart(self, event: Event, cart_id: str=None, locale='en') -> None:
|
||||
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
@@ -1055,7 +1196,7 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en') -> None:
|
||||
with language(locale):
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||
cm.clear()
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext, ugettext as _
|
||||
from django_countries.fields import Country
|
||||
@@ -21,12 +22,12 @@ from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Invoice, InvoiceAddress, InvoiceLine, Order, OrderPayment,
|
||||
Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
|
||||
)
|
||||
from pretix.base.models.tax import EU_CURRENCIES
|
||||
from pretix.base.services.tasks import TransactionAwareTask
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.base.signals import invoice_line_text, periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
from pretix.helpers.models import modelcopy
|
||||
@@ -37,9 +38,6 @@ logger = logging.getLogger(__name__)
|
||||
@transaction.atomic
|
||||
def build_invoice(invoice: Invoice) -> Invoice:
|
||||
lp = invoice.order.payments.last()
|
||||
open_payment = None
|
||||
if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED):
|
||||
open_payment = lp
|
||||
|
||||
with language(invoice.locale):
|
||||
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
|
||||
@@ -53,15 +51,19 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
|
||||
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
|
||||
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
|
||||
if open_payment and open_payment.payment_provider:
|
||||
if 'payment' in inspect.signature(open_payment.payment_provider.render_invoice_text).parameters:
|
||||
payment = open_payment.payment_provider.render_invoice_text(invoice.order, open_payment)
|
||||
if lp and lp.payment_provider:
|
||||
if 'payment' in inspect.signature(lp.payment_provider.render_invoice_text).parameters:
|
||||
payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp))
|
||||
else:
|
||||
payment = open_payment.payment_provider.render_invoice_text(invoice.order)
|
||||
elif invoice.order.status == Order.STATUS_PAID:
|
||||
payment = pgettext('invoice', 'The payment for this invoice has already been received.')
|
||||
payment = str(lp.payment_provider.render_invoice_text(invoice.order))
|
||||
else:
|
||||
payment = ""
|
||||
if invoice.event.settings.invoice_include_expire_date and invoice.order.status == Order.STATUS_PENDING:
|
||||
if payment:
|
||||
payment += "<br />"
|
||||
payment += pgettext("invoice", "Please complete your payment before {expire_date}.").format(
|
||||
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
|
||||
)
|
||||
|
||||
invoice.introductory_text = str(introductory).replace('\n', '<br />')
|
||||
invoice.additional_text = str(additional).replace('\n', '<br />')
|
||||
@@ -146,6 +148,9 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
desc = " + " + desc
|
||||
if invoice.event.settings.invoice_attendee_name and p.attendee_name:
|
||||
desc += "<br />" + pgettext("invoice", "Attendee: {name}").format(name=p.attendee_name)
|
||||
for recv, resp in invoice_line_text.send(sender=invoice.event, position=p):
|
||||
if resp:
|
||||
desc += "<br/>" + resp
|
||||
|
||||
for answ in p.answers.all():
|
||||
if not answ.question.print_on_invoice:
|
||||
@@ -181,9 +186,12 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
|
||||
offset = len(positions)
|
||||
for i, fee in enumerate(invoice.order.fees.all()):
|
||||
fee_title = _(fee.get_fee_type_display())
|
||||
if fee.description:
|
||||
fee_title += " - " + fee.description
|
||||
if fee.fee_type == OrderFee.FEE_TYPE_OTHER and fee.description:
|
||||
fee_title = fee.description
|
||||
else:
|
||||
fee_title = _(fee.get_fee_type_display())
|
||||
if fee.description:
|
||||
fee_title += " - " + fee.description
|
||||
InvoiceLine.objects.create(
|
||||
position=i + offset,
|
||||
invoice=invoice,
|
||||
@@ -210,6 +218,8 @@ def build_cancellation(invoice: Invoice):
|
||||
|
||||
|
||||
def generate_cancellation(invoice: Invoice, trigger_pdf=True):
|
||||
if invoice.canceled:
|
||||
raise ValueError("Invoice should not be canceled twice.")
|
||||
cancellation = modelcopy(invoice)
|
||||
cancellation.pk = None
|
||||
cancellation.invoice_no = None
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
import warnings
|
||||
from email.mime.image import MIMEImage
|
||||
from email.utils import formataddr
|
||||
@@ -14,9 +15,12 @@ import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from celery import chain
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.core.mail import (
|
||||
EmailMultiAlternatives, SafeMIMEMultipart, get_connection,
|
||||
)
|
||||
from django.core.mail.message import SafeMIMEText
|
||||
from django.template.loader import get_template
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import pgettext, ugettext as _
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
@@ -31,6 +35,7 @@ from pretix.base.services.tickets import get_tickets_for_order
|
||||
from pretix.base.signals import email_filter, global_email_filter
|
||||
from pretix.celery_app import app
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.presale.ical import get_ical
|
||||
|
||||
logger = logging.getLogger('pretix.base.mail')
|
||||
INVALID_ADDRESS = 'invalid-pretix-mail-address'
|
||||
@@ -50,7 +55,7 @@ class SendMailException(Exception):
|
||||
def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, event: Event=None, locale: str=None,
|
||||
order: Order=None, position: OrderPosition=None, headers: dict=None, sender: str=None,
|
||||
invoices: list=None, attach_tickets=False, auto_email=True, user=None):
|
||||
invoices: list=None, attach_tickets=False, auto_email=True, user=None, attach_ical=False):
|
||||
"""
|
||||
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
||||
|
||||
@@ -85,6 +90,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
|
||||
:param attach_tickets: Whether to attach tickets to this email, if they are available to download.
|
||||
|
||||
:param attach_ical: Whether to attach relevant ``.ics`` files to this email
|
||||
|
||||
:param auto_email: Whether this email is auto-generated
|
||||
|
||||
:param user: The user this email is sent to
|
||||
@@ -118,7 +125,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
})
|
||||
renderer = ClassicMailRenderer(None)
|
||||
content_plain = body_plain = render_mail(template, context)
|
||||
subject = str(subject).format_map(context)
|
||||
subject = str(subject).format_map(TolerantDict(context))
|
||||
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
|
||||
if event:
|
||||
sender_name = event.settings.mail_from_name or str(event.name)
|
||||
@@ -126,7 +133,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
else:
|
||||
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
|
||||
|
||||
subject = str(subject)
|
||||
subject = raw_subject = str(subject)
|
||||
signature = ""
|
||||
|
||||
bcc = []
|
||||
@@ -192,13 +199,13 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
|
||||
try:
|
||||
if 'position' in inspect.signature(renderer.render).parameters:
|
||||
body_html = renderer.render(content_plain, signature, str(subject), order, position)
|
||||
body_html = renderer.render(content_plain, signature, raw_subject, order, position)
|
||||
else:
|
||||
# Backwards compatibility
|
||||
warnings.warn('E-mail renderer called without position argument because position argument is not '
|
||||
'supported.',
|
||||
DeprecationWarning)
|
||||
body_html = renderer.render(content_plain, signature, str(subject), order)
|
||||
body_html = renderer.render(content_plain, signature, raw_subject, order)
|
||||
except:
|
||||
logger.exception('Could not render HTML body')
|
||||
body_html = None
|
||||
@@ -216,6 +223,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
order=order.pk if order else None,
|
||||
position=position.pk if position else None,
|
||||
attach_tickets=attach_tickets,
|
||||
attach_ical=attach_ical,
|
||||
user=user.pk if user else None
|
||||
)
|
||||
|
||||
@@ -228,15 +236,32 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
chain(*task_chain).apply_async()
|
||||
|
||||
|
||||
class CustomEmail(EmailMultiAlternatives):
|
||||
def _create_mime_attachment(self, content, mimetype):
|
||||
"""
|
||||
Convert the content, mimetype pair into a MIME attachment object.
|
||||
|
||||
If the mimetype is message/rfc822, content may be an
|
||||
email.Message or EmailMessage object, as well as a str.
|
||||
"""
|
||||
basetype, subtype = mimetype.split('/', 1)
|
||||
if basetype == 'multipart' and isinstance(content, SafeMIMEMultipart):
|
||||
return content
|
||||
return super()._create_mime_attachment(content, mimetype)
|
||||
|
||||
|
||||
@app.task(base=TransactionAwareTask, bind=True)
|
||||
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
event: int=None, position: int=None, headers: dict=None, bcc: List[str]=None,
|
||||
invoices: List[int]=None, order: int=None, attach_tickets=False, user=None) -> bool:
|
||||
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||
invoices: List[int]=None, order: int=None, attach_tickets=False, user=None,
|
||||
attach_ical=False) -> bool:
|
||||
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||
if html is not None:
|
||||
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
|
||||
html_with_cid, cid_images = replace_images_with_cid_paths(html)
|
||||
email = attach_cid_images(email, cid_images, verify_ssl=True)
|
||||
email.attach_alternative(html_with_cid, "text/html")
|
||||
html_message.attach(SafeMIMEText(html_with_cid, 'html', settings.DEFAULT_CHARSET))
|
||||
attach_cid_images(html_message, cid_images, verify_ssl=True)
|
||||
email.attach_alternative(html_message, "multipart/related")
|
||||
|
||||
if user:
|
||||
user = User.objects.get(pk=user)
|
||||
@@ -256,11 +281,12 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
for inv in invoices:
|
||||
if inv.file:
|
||||
try:
|
||||
email.attach(
|
||||
'{}.pdf'.format(inv.number),
|
||||
inv.file.file.read(),
|
||||
'application/pdf'
|
||||
)
|
||||
with language(inv.order.locale):
|
||||
email.attach(
|
||||
pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf',
|
||||
inv.file.file.read(),
|
||||
'application/pdf'
|
||||
)
|
||||
except:
|
||||
logger.exception('Could not attach invoice to email')
|
||||
pass
|
||||
@@ -301,6 +327,20 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
'invoices': [],
|
||||
}
|
||||
)
|
||||
if attach_ical:
|
||||
ical_events = set()
|
||||
if event.has_subevents:
|
||||
if position:
|
||||
ical_events.add(position.subevent)
|
||||
else:
|
||||
for p in order.positions.all():
|
||||
ical_events.add(p.subevent)
|
||||
else:
|
||||
ical_events.add(order.event)
|
||||
|
||||
for i, e in enumerate(ical_events):
|
||||
cal = get_ical([e])
|
||||
email.attach('event-{}.ics'.format(i), cal.serialize(), 'text/calendar')
|
||||
|
||||
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
|
||||
|
||||
@@ -326,6 +366,8 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
|
||||
raise SendMailException('Failed to send an email to {}.'.format(to))
|
||||
except Exception as e:
|
||||
if isinstance(e, (smtplib.SMTPServerDisconnected, smtplib.SMTPConnectError, ssl.SSLError, OSError)):
|
||||
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
|
||||
if order:
|
||||
order.log_action(
|
||||
'pretix.event.order.email.error',
|
||||
@@ -389,8 +431,6 @@ def attach_cid_images(msg, cid_images, verify_ssl=True):
|
||||
except:
|
||||
logger.exception("ERROR attaching CID image %s[%s]" % (cid, image))
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def encoder_linelength(msg):
|
||||
"""
|
||||
|
||||
173
src/pretix/base/services/orderimport.py
Normal file
173
src/pretix/base/services/orderimport.py
Normal file
@@ -0,0 +1,173 @@
|
||||
import csv
|
||||
import io
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Event, InvoiceAddress, Order, OrderPayment, OrderPosition,
|
||||
User,
|
||||
)
|
||||
from pretix.base.orderimport import get_all_columns
|
||||
from pretix.base.services.invoices import generate_invoice, invoice_qualified
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.base.signals import order_paid, order_placed
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
class DataImportError(LazyLocaleException):
|
||||
def __init__(self, *args):
|
||||
msg = args[0]
|
||||
msgargs = args[1] if len(args) > 1 else None
|
||||
self.args = args
|
||||
if msgargs:
|
||||
msg = _(msg) % msgargs
|
||||
else:
|
||||
msg = _(msg)
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
def parse_csv(file, length=None):
|
||||
data = file.read(length)
|
||||
try:
|
||||
import chardet
|
||||
charset = chardet.detect(data)['encoding']
|
||||
except ImportError:
|
||||
charset = file.charset
|
||||
data = data.decode(charset or 'utf-8')
|
||||
# If the file was modified on a Mac, it only contains \r as line breaks
|
||||
if '\r' in data and '\n' not in data:
|
||||
data = data.replace('\r', '\n')
|
||||
|
||||
dialect = csv.Sniffer().sniff(data.split("\n")[0], delimiters=";,.#:")
|
||||
if dialect is None:
|
||||
return None
|
||||
|
||||
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
|
||||
return reader
|
||||
|
||||
|
||||
def setif(record, obj, attr, setting):
|
||||
if setting.startswith('csv:'):
|
||||
setattr(obj, attr, record[setting[4:]] or '')
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, throws=(DataImportError,))
|
||||
def import_orders(event: Event, fileid: str, settings: dict, locale: str, user) -> None:
|
||||
# TODO: quotacheck?
|
||||
cf = CachedFile.objects.get(id=fileid)
|
||||
user = User.objects.get(pk=user)
|
||||
with language(locale):
|
||||
cols = get_all_columns(event)
|
||||
parsed = parse_csv(cf.file)
|
||||
orders = []
|
||||
order = None
|
||||
data = []
|
||||
|
||||
# Run validation
|
||||
for i, record in enumerate(parsed):
|
||||
values = {}
|
||||
for c in cols:
|
||||
val = c.resolve(settings, record)
|
||||
try:
|
||||
values[c.identifier] = c.clean(val, values)
|
||||
except ValidationError as e:
|
||||
raise DataImportError(
|
||||
_(
|
||||
'Error while importing value "{value}" for column "{column}" in line "{line}": {message}').format(
|
||||
value=val if val is not None else '', column=c.verbose_name, line=i + 1, message=e.message
|
||||
)
|
||||
)
|
||||
data.append(values)
|
||||
|
||||
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
|
||||
# shorter. We'll see what works better in reality…
|
||||
for i, record in enumerate(data):
|
||||
try:
|
||||
if order is None or settings['orders'] == 'many':
|
||||
order = Order(
|
||||
event=event,
|
||||
testmode=settings['testmode'],
|
||||
)
|
||||
order.meta_info = {}
|
||||
order._positions = []
|
||||
order._address = InvoiceAddress()
|
||||
order._address.name_parts = {'_scheme': event.settings.name_scheme}
|
||||
orders.append(order)
|
||||
|
||||
position = OrderPosition()
|
||||
position.attendee_name_parts = {'_scheme': event.settings.name_scheme}
|
||||
position.meta_info = {}
|
||||
order._positions.append(position)
|
||||
position.assign_pseudonymization_id()
|
||||
|
||||
for c in cols:
|
||||
c.assign(record.get(c.identifier), order, position, order._address)
|
||||
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
|
||||
)
|
||||
|
||||
# quota check?
|
||||
with event.lock():
|
||||
with transaction.atomic():
|
||||
for o in orders:
|
||||
o.total = sum([c.price for c in o._positions]) # currently no support for fees
|
||||
if o.total == Decimal('0.00'):
|
||||
o.status = Order.STATUS_PAID
|
||||
o.save()
|
||||
OrderPayment.objects.create(
|
||||
local_id=1,
|
||||
order=o,
|
||||
amount=Decimal('0.00'),
|
||||
provider='free',
|
||||
info='{}',
|
||||
payment_date=now(),
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
)
|
||||
elif settings['status'] == 'paid':
|
||||
o.status = Order.STATUS_PAID
|
||||
o.save()
|
||||
OrderPayment.objects.create(
|
||||
local_id=1,
|
||||
order=o,
|
||||
amount=o.total,
|
||||
provider='manual',
|
||||
info='{}',
|
||||
payment_date=now(),
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
)
|
||||
else:
|
||||
o.status = Order.STATUS_PENDING
|
||||
o.save()
|
||||
for p in o._positions:
|
||||
p.order = o
|
||||
p.save()
|
||||
o._address.order = o
|
||||
o._address.save()
|
||||
for c in cols:
|
||||
c.save(o)
|
||||
o.log_action(
|
||||
'pretix.event.order.placed',
|
||||
user=user,
|
||||
data={'source': 'import'}
|
||||
)
|
||||
|
||||
for o in orders:
|
||||
with language(o.locale):
|
||||
order_placed.send(event, order=o)
|
||||
if o.status == Order.STATUS_PAID:
|
||||
order_paid.send(event, order=o)
|
||||
|
||||
gen_invoice = invoice_qualified(o) and (
|
||||
(event.settings.get('invoice_generate') == 'True') or
|
||||
(event.settings.get('invoice_generate') == 'paid' and o.status == Order.STATUS_PAID)
|
||||
) and not o.invoices.last()
|
||||
if gen_invoice:
|
||||
generate_invoice(o, trigger_pdf=True)
|
||||
cf.delete()
|
||||
@@ -7,9 +7,10 @@ from typing import List, Optional
|
||||
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
from django.db.models import Exists, F, Max, OuterRef, Q, Sum
|
||||
from django.db.models.functions import Greatest
|
||||
from django.db.models import Exists, F, Max, Min, OuterRef, Q, Sum
|
||||
from django.db.models.functions import Coalesce, Greatest
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
@@ -17,11 +18,13 @@ from django.utils.translation import ugettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.api.models import OAuthApplication
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment,
|
||||
OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher,
|
||||
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Order,
|
||||
OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
|
||||
Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import ItemBundle
|
||||
@@ -30,7 +33,7 @@ from pretix.base.models.orders import (
|
||||
generate_secret,
|
||||
)
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
from pretix.base.models.tax import TaxedPrice, TaxRule
|
||||
from pretix.base.payment import BasePaymentProvider, PaymentException
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services import tickets
|
||||
@@ -43,8 +46,8 @@ from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
||||
from pretix.base.signals import (
|
||||
allow_ticket_download, order_approved, order_canceled, order_changed,
|
||||
order_denied, order_expired, order_fee_calculation, order_placed,
|
||||
order_split, periodic_task, validate_order,
|
||||
order_denied, order_expired, order_fee_calculation, order_paid,
|
||||
order_placed, order_split, periodic_task, validate_order,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.models import modelcopy
|
||||
@@ -67,6 +70,8 @@ error_messages = {
|
||||
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
|
||||
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum '
|
||||
'number of times allowed. We removed this item from your cart.'),
|
||||
'voucher_budget_used': _('The voucher code used for one of the items in your cart has already been too often. We '
|
||||
'adjusted the price of the item in your cart.'),
|
||||
'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item '
|
||||
'from your cart.'),
|
||||
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
|
||||
@@ -154,7 +159,7 @@ def mark_order_expired(order, user=None, auth=None):
|
||||
|
||||
order.log_action('pretix.event.order.expired', user=user, auth=auth)
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i:
|
||||
if i and not i.refered.exists():
|
||||
generate_cancellation(i)
|
||||
|
||||
order_expired.send(order.event, order=order)
|
||||
@@ -287,10 +292,23 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
|
||||
if not order.cancel_allowed():
|
||||
raise OrderError(_('You cannot cancel this order.'))
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
i = order.invoices.filter(is_cancellation=False, refered__isnull=True).last()
|
||||
if i:
|
||||
generate_cancellation(i)
|
||||
|
||||
for position in order.positions.all():
|
||||
for gc in position.issued_gift_cards.all():
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gc.pk)
|
||||
if gc.value < position.price:
|
||||
raise OrderError(
|
||||
_('This order can not be canceled since the gift card {card} purchased in '
|
||||
'this order has already been redeemed.').format(
|
||||
card=gc.secret
|
||||
)
|
||||
)
|
||||
else:
|
||||
gc.transactions.create(value=-position.price, order=order)
|
||||
|
||||
if cancellation_fee:
|
||||
with order.event.lock():
|
||||
for position in order.positions.all():
|
||||
@@ -344,6 +362,31 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
except SendMailException:
|
||||
logger.exception('Order canceled email could not be sent')
|
||||
|
||||
for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING)):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
p.payment_provider.cancel_payment(p)
|
||||
order.log_action(
|
||||
'pretix.event.order.payment.canceled',
|
||||
{
|
||||
'local_id': p.local_id,
|
||||
'provider': p.provider,
|
||||
},
|
||||
user=user,
|
||||
auth=api_token or oauth_application or device
|
||||
)
|
||||
except PaymentException as e:
|
||||
order.log_action(
|
||||
'pretix.event.order.payment.canceled.failed',
|
||||
{
|
||||
'local_id': p.local_id,
|
||||
'provider': p.provider,
|
||||
'error': str(e)
|
||||
},
|
||||
user=user,
|
||||
auth=api_token or oauth_application or device
|
||||
)
|
||||
|
||||
order_canceled.send(order.event, order=order)
|
||||
return order.pk
|
||||
|
||||
@@ -377,13 +420,15 @@ def _check_date(event: Event, now_dt: datetime):
|
||||
raise OrderError(error_messages['ended'])
|
||||
|
||||
|
||||
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None):
|
||||
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None,
|
||||
sales_channel='web'):
|
||||
err = None
|
||||
errargs = None
|
||||
_check_date(event, now_dt)
|
||||
|
||||
products_seen = Counter()
|
||||
changed_prices = {}
|
||||
v_budget = {}
|
||||
deleted_positions = set()
|
||||
seats_seen = set()
|
||||
|
||||
@@ -426,6 +471,20 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
delete(cp)
|
||||
continue
|
||||
|
||||
if cp.voucher.budget is not None:
|
||||
if cp.voucher not in v_budget:
|
||||
v_budget[cp.voucher] = cp.voucher.budget - cp.voucher.budget_used()
|
||||
disc = cp.price_before_voucher - cp.price
|
||||
if disc > v_budget[cp.voucher]:
|
||||
new_disc = max(0, v_budget[cp.voucher])
|
||||
cp.price = cp.price + (disc - new_disc)
|
||||
cp.save()
|
||||
err = err or error_messages['voucher_budget_used']
|
||||
v_budget[cp.voucher] -= new_disc
|
||||
continue
|
||||
else:
|
||||
v_budget[cp.voucher] -= disc
|
||||
|
||||
if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start:
|
||||
err = err or error_messages['some_subevent_not_started']
|
||||
delete(cp)
|
||||
@@ -469,9 +528,9 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
break
|
||||
|
||||
if cp.seat:
|
||||
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every time, since we absolutely
|
||||
# can not overbook a seat.
|
||||
if not cp.seat.is_available(ignore_cart=cp) or cp.seat.blocked:
|
||||
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every
|
||||
# time, since we absolutely can not overbook a seat.
|
||||
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel):
|
||||
err = err or error_messages['seat_unavailable']
|
||||
cp.delete()
|
||||
continue
|
||||
@@ -480,6 +539,11 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
# Other checks are not necessary
|
||||
continue
|
||||
|
||||
max_discount = None
|
||||
if cp.price_before_voucher is not None and cp.voucher in v_budget:
|
||||
current_discount = cp.price_before_voucher - cp.price
|
||||
max_discount = max(v_budget[cp.voucher] + current_discount, 0)
|
||||
|
||||
if cp.is_bundled:
|
||||
try:
|
||||
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
|
||||
@@ -487,7 +551,9 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
except ItemBundle.DoesNotExist:
|
||||
bprice = cp.price
|
||||
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
|
||||
invoice_address=address, force_custom_price=True)
|
||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||
pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False,
|
||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||
changed_prices[cp.pk] = bprice
|
||||
else:
|
||||
bundled_sum = 0
|
||||
@@ -497,7 +563,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
bundled_sum += changed_prices.get(bundledp.pk, bundledp.price)
|
||||
|
||||
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
|
||||
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum)
|
||||
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
|
||||
max_discount=max_discount)
|
||||
pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False,
|
||||
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
|
||||
max_discount=max_discount)
|
||||
|
||||
if max_discount is not None:
|
||||
v_budget[cp.voucher] = v_budget[cp.voucher] + current_discount - (pbv.gross - price.gross)
|
||||
|
||||
if price is False or len(quotas) == 0:
|
||||
err = err or error_messages['unavailable']
|
||||
@@ -510,6 +583,11 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
delete(cp)
|
||||
continue
|
||||
|
||||
if pbv is not None and pbv.gross != price.gross:
|
||||
cp.price_before_voucher = pbv.gross
|
||||
else:
|
||||
cp.price_before_voucher = None
|
||||
|
||||
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
|
||||
cp.price = price.gross
|
||||
cp.includes_tax = bool(price.rate)
|
||||
@@ -545,7 +623,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
|
||||
|
||||
def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvider, address: InvoiceAddress,
|
||||
meta_info: dict, event: Event):
|
||||
meta_info: dict, event: Event, gift_cards: List[GiftCard]):
|
||||
fees = []
|
||||
total = sum([c.price for c in positions])
|
||||
|
||||
@@ -553,8 +631,16 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
|
||||
meta_info=meta_info, positions=positions):
|
||||
if resp:
|
||||
fees += resp
|
||||
|
||||
total += sum(f.value for f in fees)
|
||||
|
||||
gift_card_values = {}
|
||||
for gc in gift_cards:
|
||||
fval = Decimal(gc.value) # TODO: don't require an extra query
|
||||
fval = min(fval, total)
|
||||
if fval > 0:
|
||||
total -= fval
|
||||
gift_card_values[gc] = fval
|
||||
|
||||
if payment_provider:
|
||||
payment_fee = payment_provider.calculate_fee(total)
|
||||
else:
|
||||
@@ -565,17 +651,36 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
|
||||
internal_type=payment_provider.identifier)
|
||||
fees.append(pf)
|
||||
|
||||
return fees, pf
|
||||
return fees, pf, gift_card_values
|
||||
|
||||
|
||||
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
|
||||
meta_info: dict=None, sales_channel: str='web'):
|
||||
fees, pf = _get_fees(positions, payment_provider, address, meta_info, event)
|
||||
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
||||
meta_info: dict=None, sales_channel: str='web', gift_cards: list=None,
|
||||
shown_total=None):
|
||||
p = None
|
||||
sales_channel = get_all_sales_channels()[sales_channel]
|
||||
|
||||
with transaction.atomic():
|
||||
checked_gift_cards = []
|
||||
if gift_cards:
|
||||
gc_qs = GiftCard.objects.select_for_update().filter(pk__in=gift_cards)
|
||||
for gc in gc_qs:
|
||||
if gc.currency != event.currency:
|
||||
raise OrderError(_("This gift card does not support this currency."))
|
||||
if gc.testmode and not event.testmode:
|
||||
raise OrderError(_("This gift card can only be used in test mode."))
|
||||
if not gc.testmode and event.testmode:
|
||||
raise OrderError(_("Only test gift cards can be used in test mode."))
|
||||
if not gc.accepted_by(event.organizer):
|
||||
raise OrderError(_("This gift card is not accepted by this event organizer."))
|
||||
checked_gift_cards.append(gc)
|
||||
if checked_gift_cards and any(c.item.issue_giftcard for c in positions):
|
||||
raise OrderError(_("You cannot pay with gift cards when buying a gift card."))
|
||||
|
||||
fees, pf, gift_card_values = _get_fees(positions, payment_provider, address, meta_info, event, checked_gift_cards)
|
||||
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
||||
|
||||
order = Order(
|
||||
status=Order.STATUS_PENDING,
|
||||
event=event,
|
||||
@@ -583,10 +688,10 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
datetime=now_dt,
|
||||
locale=locale,
|
||||
total=total,
|
||||
testmode=event.testmode,
|
||||
testmode=True if sales_channel.testmode_supported and event.testmode else False,
|
||||
meta_info=json.dumps(meta_info or {}),
|
||||
require_approval=any(p.item.require_approval for p in positions),
|
||||
sales_channel=sales_channel
|
||||
sales_channel=sales_channel.identifier
|
||||
)
|
||||
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
|
||||
order.save()
|
||||
@@ -606,11 +711,41 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
fee.tax_rule = None # TODO: deprecate
|
||||
fee.save()
|
||||
|
||||
for gc, val in gift_card_values.items():
|
||||
p = order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
provider='giftcard',
|
||||
amount=val,
|
||||
fee=pf
|
||||
)
|
||||
trans = gc.transactions.create(
|
||||
value=-1 * val,
|
||||
order=order,
|
||||
payment=p
|
||||
)
|
||||
p.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'transaction_id': trans.pk,
|
||||
}
|
||||
p.save()
|
||||
pending_sum -= val
|
||||
|
||||
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
|
||||
# pressed "Confirm purchase"? If not, we should better warn the user and show the confirmation page again.
|
||||
# The only *known* case where this happens is if a gift card is used in two concurrent sessions.
|
||||
if shown_total is not None:
|
||||
if Decimal(shown_total) != pending_sum:
|
||||
raise OrderError(
|
||||
_('While trying to place your order, we noticed that the order total has changed. Either one of '
|
||||
'the prices changed just now, or a gift card you used has been used in the meantime. Please '
|
||||
'check the prices below and try again.')
|
||||
)
|
||||
|
||||
if payment_provider and not order.require_approval:
|
||||
p = order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
provider=payment_provider.identifier,
|
||||
amount=total,
|
||||
amount=pending_sum,
|
||||
fee=pf
|
||||
)
|
||||
|
||||
@@ -635,7 +770,8 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider,
|
||||
email_subject, email_template, email_context,
|
||||
log_entry,
|
||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True
|
||||
attach_tickets=True,
|
||||
attach_ical=event.settings.mail_attach_ical
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order received email could not be sent')
|
||||
@@ -651,14 +787,16 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
|
||||
log_entry,
|
||||
invoices=[],
|
||||
attach_tickets=True,
|
||||
position=position
|
||||
position=position,
|
||||
attach_ical=event.settings.mail_attach_ical
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order received email could not be sent to attendee')
|
||||
|
||||
|
||||
def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'):
|
||||
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
|
||||
gift_cards: list=None, shown_total=None):
|
||||
if payment_provider:
|
||||
pprov = event.get_payment_providers().get(payment_provider)
|
||||
if not pprov:
|
||||
@@ -700,16 +838,20 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
lockfn = event.lock
|
||||
|
||||
with lockfn() as now_dt:
|
||||
positions = list(positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons'))
|
||||
positions = list(
|
||||
positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')
|
||||
)
|
||||
positions.sort(key=lambda k: position_ids.index(k.pk))
|
||||
if len(positions) == 0:
|
||||
raise OrderError(error_messages['empty'])
|
||||
if len(position_ids) != len(positions):
|
||||
raise OrderError(error_messages['internal'])
|
||||
_check_positions(event, now_dt, positions, address=addr)
|
||||
_check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel)
|
||||
order, payment = _create_order(event, email, positions, now_dt, pprov,
|
||||
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel)
|
||||
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
|
||||
gift_cards=gift_cards, shown_total=shown_total)
|
||||
|
||||
free_order_flow = payment and payment_provider == 'free' and order.total == Decimal('0.00') and not order.require_approval
|
||||
free_order_flow = payment and payment_provider == 'free' and order.pending_sum == Decimal('0.00') and not order.require_approval
|
||||
if free_order_flow:
|
||||
try:
|
||||
payment.confirm(send_mail=False, lock=not locked)
|
||||
@@ -756,14 +898,14 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def expire_orders(sender, **kwargs):
|
||||
eventcache = {}
|
||||
event_id = None
|
||||
expire = None
|
||||
|
||||
for o in Order.objects.filter(expires__lt=now(), status=Order.STATUS_PENDING,
|
||||
require_approval=False).select_related('event'):
|
||||
expire = eventcache.get(o.event.pk, None)
|
||||
if expire is None:
|
||||
require_approval=False).select_related('event').order_by('event_id'):
|
||||
if o.event_id != event_id:
|
||||
expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)
|
||||
eventcache[o.event.pk] = expire
|
||||
event_id = o.event_id
|
||||
if expire:
|
||||
mark_order_expired(o)
|
||||
|
||||
@@ -771,31 +913,35 @@ def expire_orders(sender, **kwargs):
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def send_expiry_warnings(sender, **kwargs):
|
||||
eventcache = {}
|
||||
today = now().replace(hour=0, minute=0, second=0)
|
||||
days = None
|
||||
settings = None
|
||||
event_id = None
|
||||
|
||||
for o in Order.objects.filter(
|
||||
expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING,
|
||||
datetime__lte=now() - timedelta(hours=2), require_approval=False
|
||||
).only('pk'):
|
||||
with transaction.atomic():
|
||||
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
|
||||
if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent:
|
||||
# Race condition
|
||||
continue
|
||||
eventsettings = eventcache.get(o.event.pk, None)
|
||||
if eventsettings is None:
|
||||
eventsettings = o.event.settings
|
||||
eventcache[o.event.pk] = eventsettings
|
||||
).only('pk', 'event_id', 'expires').order_by('event_id'):
|
||||
if event_id != o.event_id:
|
||||
settings = o.event.settings
|
||||
days = cache.get_or_set('{}:{}:setting_mail_days_order_expire_warning'.format('event', o.event_id),
|
||||
default=lambda: settings.get('mail_days_order_expire_warning', as_type=int),
|
||||
timeout=3600)
|
||||
event_id = o.event_id
|
||||
|
||||
if days and (o.expires - today).days <= days:
|
||||
with transaction.atomic():
|
||||
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
|
||||
if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent:
|
||||
# Race condition
|
||||
continue
|
||||
|
||||
days = eventsettings.get('mail_days_order_expire_warning', as_type=int)
|
||||
if days and (o.expires - today).days <= days:
|
||||
with language(o.locale):
|
||||
o.expiry_reminder_sent = True
|
||||
o.save(update_fields=['expiry_reminder_sent'])
|
||||
email_template = eventsettings.mail_text_order_expire_warning
|
||||
email_template = settings.mail_text_order_expire_warning
|
||||
email_context = get_email_context(event=o.event, order=o)
|
||||
if eventsettings.payment_term_expire_automatically:
|
||||
if settings.payment_term_expire_automatically:
|
||||
email_subject = _('Your order is about to expire: %(code)s') % {'code': o.code}
|
||||
else:
|
||||
email_subject = _('Your order is pending payment: %(code)s') % {'code': o.code}
|
||||
@@ -813,54 +959,76 @@ def send_expiry_warnings(sender, **kwargs):
|
||||
@scopes_disabled()
|
||||
def send_download_reminders(sender, **kwargs):
|
||||
today = now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
qs = Order.objects.annotate(
|
||||
first_date=Coalesce(
|
||||
Min('all_positions__subevent__date_from'),
|
||||
F('event__date_from')
|
||||
)
|
||||
).filter(
|
||||
status=Order.STATUS_PAID,
|
||||
download_reminder_sent=False,
|
||||
datetime__lte=now() - timedelta(hours=2),
|
||||
first_date__gte=today,
|
||||
).only('pk', 'event_id').order_by('event_id')
|
||||
event_id = None
|
||||
days = None
|
||||
event = None
|
||||
|
||||
for e in Event.objects.filter(date_from__gte=today):
|
||||
for o in qs:
|
||||
if o.event_id != event_id:
|
||||
days = o.event.settings.get('mail_days_download_reminder', as_type=int)
|
||||
event = o.event
|
||||
event_id = o.event_id
|
||||
|
||||
days = e.settings.get('mail_days_download_reminder', as_type=int)
|
||||
if days is None:
|
||||
continue
|
||||
|
||||
reminder_date = (e.date_from - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
if now() < reminder_date:
|
||||
reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if now() < reminder_date or o.datetime > reminder_date:
|
||||
continue
|
||||
for o in e.orders.filter(status=Order.STATUS_PAID, download_reminder_sent=False, datetime__lte=now() - timedelta(hours=2)).only('pk'):
|
||||
with transaction.atomic():
|
||||
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
|
||||
if o.download_reminder_sent:
|
||||
# Race condition
|
||||
continue
|
||||
if not all([r for rr, r in allow_ticket_download.send(e, order=o)]):
|
||||
continue
|
||||
|
||||
with language(o.locale):
|
||||
o.download_reminder_sent = True
|
||||
o.save(update_fields=['download_reminder_sent'])
|
||||
email_template = e.settings.mail_text_download_reminder
|
||||
email_context = get_email_context(event=e, order=o)
|
||||
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.download_reminder_sent',
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
with transaction.atomic():
|
||||
o = Order.objects.select_for_update().get(pk=o.pk)
|
||||
if o.download_reminder_sent:
|
||||
# Race condition
|
||||
continue
|
||||
if not all([r for rr, r in allow_ticket_download.send(event, order=o)]):
|
||||
continue
|
||||
|
||||
if e.settings.mail_send_download_reminder_attendee:
|
||||
for p in o.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
|
||||
email_template = e.settings.mail_text_download_reminder_attendee
|
||||
email_context = get_email_context(event=e, order=o, position=p)
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.download_reminder_sent',
|
||||
attach_tickets=True, position=p
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent to attendee')
|
||||
with language(o.locale):
|
||||
o.download_reminder_sent = True
|
||||
o.save(update_fields=['download_reminder_sent'])
|
||||
email_template = event.settings.mail_text_download_reminder
|
||||
email_context = get_email_context(event=event, order=o)
|
||||
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.download_reminder_sent',
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
|
||||
if event.settings.mail_send_download_reminder_attendee:
|
||||
for p in o.positions.all():
|
||||
if p.subevent_id:
|
||||
reminder_date = (p.subevent.date_from - timedelta(days=days)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
if now() < reminder_date:
|
||||
continue
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
|
||||
email_template = event.settings.mail_text_download_reminder_attendee
|
||||
email_context = get_email_context(event=event, order=o, position=p)
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.download_reminder_sent',
|
||||
attach_tickets=True, position=p
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent to attendee')
|
||||
|
||||
|
||||
def notify_user_changed_order(order, user=None, auth=None):
|
||||
@@ -894,6 +1062,7 @@ class OrderChangeManager:
|
||||
'seat_subevent_mismatch': _('You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'),
|
||||
'seat_required': _('The selected product requires you to select a seat.'),
|
||||
'seat_forbidden': _('The selected product does not allow to select a seat.'),
|
||||
'gift_card_change': _('You cannot change the price of a position that has been used to issue a gift card.'),
|
||||
}
|
||||
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
|
||||
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
|
||||
@@ -902,6 +1071,8 @@ class OrderChangeManager:
|
||||
CancelOperation = namedtuple('CancelOperation', ('position',))
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat'))
|
||||
SplitOperation = namedtuple('SplitOperation', ('position',))
|
||||
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value'))
|
||||
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee',))
|
||||
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
|
||||
|
||||
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True):
|
||||
@@ -933,6 +1104,21 @@ class OrderChangeManager:
|
||||
self._operations.append(self.ItemOperation(position, item, variation))
|
||||
|
||||
def change_seat(self, position: OrderPosition, seat: Seat):
|
||||
if isinstance(seat, str):
|
||||
subev = None
|
||||
if self.event.has_subevents:
|
||||
subev = position.subevent
|
||||
for p in self._operations:
|
||||
if isinstance(p, self.SubeventOperation) and p.position == position:
|
||||
subev = p.subevent
|
||||
try:
|
||||
seat = Seat.objects.get(
|
||||
event=self.event,
|
||||
subevent=subev,
|
||||
seat_guid=seat
|
||||
)
|
||||
except Seat.DoesNotExist:
|
||||
raise OrderError(error_messages['seat_invalid'])
|
||||
if position.seat:
|
||||
self._seatdiff.subtract([position.seat])
|
||||
if seat:
|
||||
@@ -961,6 +1147,9 @@ class OrderChangeManager:
|
||||
def change_price(self, position: OrderPosition, price: Decimal):
|
||||
price = position.item.tax(price, base_price_is='gross')
|
||||
|
||||
if position.issued_gift_cards.exists():
|
||||
raise OrderError(self.error_messages['gift_card_change'])
|
||||
|
||||
self._totaldiff += price.gross - position.price
|
||||
|
||||
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
|
||||
@@ -990,8 +1179,19 @@ class OrderChangeManager:
|
||||
self._totaldiff += price.gross - pos.price
|
||||
self._operations.append(self.PriceOperation(pos, price))
|
||||
|
||||
def cancel_fee(self, fee: OrderFee):
|
||||
self._totaldiff -= fee.value
|
||||
self._operations.append(self.CancelFeeOperation(fee))
|
||||
self._invoice_dirty = True
|
||||
|
||||
def change_fee(self, fee: OrderFee, value: Decimal):
|
||||
value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross')
|
||||
self._totaldiff += value.gross - fee.value
|
||||
self._invoice_dirty = True
|
||||
self._operations.append(self.FeeValueOperation(fee, value))
|
||||
|
||||
def cancel(self, position: OrderPosition):
|
||||
self._totaldiff += -position.price
|
||||
self._totaldiff -= position.price
|
||||
self._quotadiff.subtract(position.quotas)
|
||||
self._operations.append(self.CancelOperation(position))
|
||||
if position.seat:
|
||||
@@ -1002,6 +1202,19 @@ class OrderChangeManager:
|
||||
|
||||
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
|
||||
subevent: SubEvent = None, seat: Seat = None):
|
||||
if isinstance(seat, str):
|
||||
if not seat:
|
||||
seat = None
|
||||
else:
|
||||
try:
|
||||
seat = Seat.objects.get(
|
||||
event=self.event,
|
||||
subevent=subevent,
|
||||
seat_guid=seat
|
||||
)
|
||||
except Seat.DoesNotExist:
|
||||
raise OrderError(error_messages['seat_invalid'])
|
||||
|
||||
if price is None:
|
||||
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
|
||||
else:
|
||||
@@ -1025,7 +1238,7 @@ class OrderChangeManager:
|
||||
raise OrderError(self.error_messages['seat_required'])
|
||||
elif not seated and seat:
|
||||
raise OrderError(self.error_messages['seat_forbidden'])
|
||||
if seat and subevent and seat.subevent_id != subevent:
|
||||
if seat and subevent and seat.subevent_id != subevent.pk:
|
||||
raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=seat.name))
|
||||
|
||||
new_quotas = (variation.quotas.filter(subevent=subevent)
|
||||
@@ -1052,7 +1265,7 @@ class OrderChangeManager:
|
||||
for seat, diff in self._seatdiff.items():
|
||||
if diff <= 0:
|
||||
continue
|
||||
if not seat.is_available() or diff > 1:
|
||||
if not seat.is_available(sales_channel=self.order.sales_channel) or diff > 1:
|
||||
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
|
||||
|
||||
if self.event.has_subevents:
|
||||
@@ -1090,36 +1303,71 @@ class OrderChangeManager:
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
elif self.open_payment:
|
||||
self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED
|
||||
self.open_payment.save()
|
||||
self.order.log_action('pretix.event.order.payment.canceled', {
|
||||
'local_id': self.open_payment.local_id,
|
||||
'provider': self.open_payment.provider,
|
||||
}, user=self.user, auth=self.auth)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
self.open_payment.payment_provider.cancel_payment(self.open_payment)
|
||||
self.order.log_action(
|
||||
'pretix.event.order.payment.canceled',
|
||||
{
|
||||
'local_id': self.open_payment.local_id,
|
||||
'provider': self.open_payment.provider,
|
||||
},
|
||||
user=self.user,
|
||||
auth=self.auth
|
||||
)
|
||||
except PaymentException as e:
|
||||
self.order.log_action(
|
||||
'pretix.event.order.payment.canceled.failed',
|
||||
{
|
||||
'local_id': self.open_payment.local_id,
|
||||
'provider': self.open_payment.provider,
|
||||
'error': str(e)
|
||||
},
|
||||
user=self.user,
|
||||
auth=self.auth
|
||||
)
|
||||
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0:
|
||||
if self.open_payment:
|
||||
self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED
|
||||
self.open_payment.save()
|
||||
self.order.log_action('pretix.event.order.payment.canceled', {
|
||||
'local_id': self.open_payment.local_id,
|
||||
'provider': self.open_payment.provider,
|
||||
}, user=self.user, auth=self.auth)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
self.open_payment.payment_provider.cancel_payment(self.open_payment)
|
||||
self.order.log_action('pretix.event.order.payment.canceled', {
|
||||
'local_id': self.open_payment.local_id,
|
||||
'provider': self.open_payment.provider,
|
||||
}, user=self.user, auth=self.auth)
|
||||
except PaymentException as e:
|
||||
self.order.log_action(
|
||||
'pretix.event.order.payment.canceled.failed',
|
||||
{
|
||||
'local_id': self.open_payment.local_id,
|
||||
'provider': self.open_payment.provider,
|
||||
'error': str(e)
|
||||
},
|
||||
user=self.user,
|
||||
auth=self.auth,
|
||||
)
|
||||
|
||||
def _check_paid_to_free(self):
|
||||
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
|
||||
# if the order becomes free, mark it paid using the 'free' provider
|
||||
# this could happen if positions have been made cheaper or removed (_totaldiff < 0)
|
||||
# or positions got split off to a new order (split_order with positive total)
|
||||
p = self.order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
provider='free',
|
||||
amount=0,
|
||||
fee=None
|
||||
)
|
||||
try:
|
||||
p.confirm(send_mail=False, count_waitinglist=False, user=self.user, auth=self.auth)
|
||||
except Quota.QuotaExceededException:
|
||||
raise OrderError(self.error_messages['paid_to_free_exceeded'])
|
||||
if not self.order.fees.exists() and not self.order.positions.exists():
|
||||
# The order is completely empty now, so we cancel it.
|
||||
self.order.status = Order.STATUS_CANCELED
|
||||
self.order.save(update_fields=['status'])
|
||||
order_canceled.send(self.order.event, order=self.order)
|
||||
else:
|
||||
# if the order becomes free, mark it paid using the 'free' provider
|
||||
# this could happen if positions have been made cheaper or removed (_totaldiff < 0)
|
||||
# or positions got split off to a new order (split_order with positive total)
|
||||
p = self.order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
provider='free',
|
||||
amount=0,
|
||||
fee=None
|
||||
)
|
||||
try:
|
||||
p.confirm(send_mail=False, count_waitinglist=False, user=self.user, auth=self.auth)
|
||||
except Quota.QuotaExceededException:
|
||||
raise OrderError(self.error_messages['paid_to_free_exceeded'])
|
||||
|
||||
if self.split_order and self.split_order.total == 0 and not self.split_order.require_approval:
|
||||
p = self.split_order.payments.create(
|
||||
@@ -1153,6 +1401,16 @@ class OrderChangeManager:
|
||||
op.position.item = op.item
|
||||
op.position.variation = op.variation
|
||||
op.position._calculate_tax()
|
||||
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
|
||||
op.position.price_before_voucher = max(
|
||||
op.position.price,
|
||||
get_price(
|
||||
op.position.item, op.position.variation,
|
||||
subevent=op.position.subevent,
|
||||
custom_price=op.position.price,
|
||||
invoice_address=self._invoice_address
|
||||
).gross
|
||||
)
|
||||
op.position.save()
|
||||
elif isinstance(op, self.SeatOperation):
|
||||
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
|
||||
@@ -1176,6 +1434,25 @@ class OrderChangeManager:
|
||||
})
|
||||
op.position.subevent = op.subevent
|
||||
op.position.save()
|
||||
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
|
||||
op.position.price_before_voucher = max(
|
||||
op.position.price,
|
||||
get_price(
|
||||
op.position.item, op.position.variation,
|
||||
subevent=op.position.subevent,
|
||||
custom_price=op.position.price,
|
||||
invoice_address=self._invoice_address
|
||||
).gross
|
||||
)
|
||||
elif isinstance(op, self.FeeValueOperation):
|
||||
self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={
|
||||
'fee': op.fee.pk,
|
||||
'old_price': op.fee.value,
|
||||
'new_price': op.value.gross
|
||||
})
|
||||
op.fee.value = op.value.gross
|
||||
op.fee._calculate_tax()
|
||||
op.fee.save()
|
||||
elif isinstance(op, self.PriceOperation):
|
||||
self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={
|
||||
'position': op.position.pk,
|
||||
@@ -1187,7 +1464,26 @@ class OrderChangeManager:
|
||||
op.position.price = op.price.gross
|
||||
op.position._calculate_tax()
|
||||
op.position.save()
|
||||
elif isinstance(op, self.CancelFeeOperation):
|
||||
self.order.log_action('pretix.event.order.changed.cancelfee', user=self.user, auth=self.auth, data={
|
||||
'fee': op.fee.pk,
|
||||
'fee_type': op.fee.fee_type,
|
||||
'old_price': op.fee.value,
|
||||
})
|
||||
op.fee.canceled = True
|
||||
op.fee.save(update_fields=['canceled'])
|
||||
elif isinstance(op, self.CancelOperation):
|
||||
for gc in op.position.issued_gift_cards.all():
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gc.pk)
|
||||
if gc.value < op.position.price:
|
||||
raise OrderError(_(
|
||||
'A position can not be canceled since the gift card {card} purchased in this order has '
|
||||
'already been redeemed.').format(
|
||||
card=gc.secret
|
||||
))
|
||||
else:
|
||||
gc.transactions.create(value=-op.position.price, order=self.order)
|
||||
|
||||
for opa in op.position.addons.all():
|
||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
|
||||
'position': opa.pk,
|
||||
@@ -1303,19 +1599,25 @@ class OrderChangeManager:
|
||||
fee.delete()
|
||||
split_order.total += fee.value
|
||||
|
||||
remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
|
||||
offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total)
|
||||
if offset_amount >= split_order.total:
|
||||
split_order.status = Order.STATUS_PAID
|
||||
else:
|
||||
split_order.status = Order.STATUS_PENDING
|
||||
split_order.save()
|
||||
|
||||
if split_order.status == Order.STATUS_PAID:
|
||||
if offset_amount > Decimal('0.00'):
|
||||
split_order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
amount=split_order.total,
|
||||
amount=offset_amount,
|
||||
payment_date=now(),
|
||||
provider='offsetting',
|
||||
info=json.dumps({'orders': [self.order.code]})
|
||||
)
|
||||
self.order.refunds.create(
|
||||
state=OrderRefund.REFUND_STATE_DONE,
|
||||
amount=split_order.total,
|
||||
amount=offset_amount,
|
||||
execution_date=now(),
|
||||
provider='offsetting',
|
||||
info=json.dumps({'orders': [split_order.code]})
|
||||
@@ -1352,10 +1654,19 @@ class OrderChangeManager:
|
||||
fee = None
|
||||
if self.open_payment.fee:
|
||||
fee = self.open_payment.fee
|
||||
current_fee = self.open_payment.fee.value
|
||||
if any(isinstance(op, (self.FeeValueOperation, self.CancelFeeOperation)) for op in self._operations):
|
||||
fee.refresh_from_db()
|
||||
if not self.open_payment.fee.canceled:
|
||||
current_fee = self.open_payment.fee.value
|
||||
total -= current_fee
|
||||
|
||||
if self.order.pending_sum - current_fee != 0:
|
||||
if fee and any([isinstance(op, self.FeeValueOperation) and op.fee == fee for op in self._operations]):
|
||||
# Do not automatically modify a fee that is being manually modified right now
|
||||
payment_fee = fee.value
|
||||
elif fee and any([isinstance(op, self.CancelFeeOperation) and op.fee == fee for op in self._operations]):
|
||||
# Do not automatically modify a fee that is being manually removed right now
|
||||
payment_fee = Decimal('0.00')
|
||||
elif self.order.pending_sum - current_fee != 0:
|
||||
prov = self.open_payment.payment_provider
|
||||
if prov:
|
||||
payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
|
||||
@@ -1368,7 +1679,7 @@ class OrderChangeManager:
|
||||
if not self.open_payment.fee:
|
||||
self.open_payment.fee = fee
|
||||
self.open_payment.save(update_fields=['fee'])
|
||||
elif fee:
|
||||
elif fee and not fee.canceled:
|
||||
fee.delete()
|
||||
|
||||
self.order.total = total + payment_fee
|
||||
@@ -1398,9 +1709,10 @@ class OrderChangeManager:
|
||||
generate_invoice(self.order)
|
||||
|
||||
def _check_complete_cancel(self):
|
||||
current = self.order.positions.count()
|
||||
cancels = len([o for o in self._operations if isinstance(o, (self.CancelOperation, self.SplitOperation))])
|
||||
adds = len([o for o in self._operations if isinstance(o, self.AddOperation)])
|
||||
if self.order.positions.count() - cancels + adds < 1:
|
||||
if current > 0 and current - cancels + adds < 1:
|
||||
raise OrderError(self.error_messages['complete_cancel'])
|
||||
|
||||
@property
|
||||
@@ -1466,12 +1778,12 @@ class OrderChangeManager:
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def perform_order(self, event: Event, payment_provider: str, positions: List[str],
|
||||
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
|
||||
sales_channel: str='web'):
|
||||
sales_channel: str='web', gift_cards: list=None, shown_total=None):
|
||||
with language(locale):
|
||||
try:
|
||||
try:
|
||||
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info,
|
||||
sales_channel)
|
||||
sales_channel, gift_cards, shown_total)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
@@ -1583,8 +1895,22 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
|
||||
|
||||
if open_payment and open_payment.state in (OrderPayment.PAYMENT_STATE_PENDING,
|
||||
OrderPayment.PAYMENT_STATE_CREATED):
|
||||
open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED
|
||||
open_payment.save(update_fields=['state'])
|
||||
try:
|
||||
with transaction.atomic():
|
||||
open_payment.payment_provider.cancel_payment(open_payment)
|
||||
order.log_action('pretix.event.order.payment.canceled', {
|
||||
'local_id': open_payment.local_id,
|
||||
'provider': open_payment.provider,
|
||||
})
|
||||
except PaymentException as e:
|
||||
order.log_action(
|
||||
'pretix.event.order.payment.canceled.failed',
|
||||
{
|
||||
'local_id': open_payment.local_id,
|
||||
'provider': open_payment.provider,
|
||||
'error': str(e)
|
||||
},
|
||||
)
|
||||
|
||||
order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0)
|
||||
order.save(update_fields=['total'])
|
||||
@@ -1610,8 +1936,30 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
|
||||
|
||||
if recreate_invoices:
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i and order.total != oldtotal:
|
||||
if i and order.total != oldtotal and not i.canceled:
|
||||
generate_cancellation(i)
|
||||
generate_invoice(order)
|
||||
|
||||
return old_fee, new_fee, fee, new_payment
|
||||
|
||||
|
||||
@receiver(order_paid, dispatch_uid="pretixbase_order_paid_giftcards")
|
||||
@transaction.atomic()
|
||||
def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs):
|
||||
any_giftcards = False
|
||||
for p in order.positions.all():
|
||||
if p.item.issue_giftcard:
|
||||
issued = Decimal('0.00')
|
||||
for gc in p.issued_gift_cards.all():
|
||||
issued += gc.transactions.first().value
|
||||
if p.price - issued > 0:
|
||||
gc = sender.organizer.issued_gift_cards.create(
|
||||
currency=sender.currency, issued_in=p, testmode=order.testmode
|
||||
)
|
||||
gc.transactions.create(value=p.price - issued, order=order)
|
||||
any_giftcards = True
|
||||
p.secret = gc.secret
|
||||
p.save(update_fields=['secret'])
|
||||
|
||||
if any_giftcards:
|
||||
tickets.invalidate_cache.apply_async(kwargs={'event': sender.pk, 'order': order.pk})
|
||||
|
||||
@@ -12,7 +12,8 @@ def get_price(item: Item, variation: ItemVariation = None,
|
||||
voucher: Voucher = None, custom_price: Decimal = None,
|
||||
subevent: SubEvent = None, custom_price_is_net: bool = False,
|
||||
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None,
|
||||
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00')) -> TaxedPrice:
|
||||
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00'),
|
||||
max_discount: Decimal = None) -> TaxedPrice:
|
||||
if addon_to:
|
||||
try:
|
||||
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
|
||||
@@ -32,7 +33,7 @@ def get_price(item: Item, variation: ItemVariation = None,
|
||||
price = subevent.var_price_overrides[variation.pk]
|
||||
|
||||
if voucher:
|
||||
price = voucher.calculate_price(price)
|
||||
price = voucher.calculate_price(price, max_discount=max_discount)
|
||||
|
||||
if item.tax_rule:
|
||||
tax_rule = item.tax_rule
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user