forked from CGM_Public/pretix_original
Compare commits
267 Commits
v1.16.0
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41d099c1be | ||
|
|
ff306ce2c5 | ||
|
|
c7abc82055 | ||
|
|
041d91dd3c | ||
|
|
387f56ed9b | ||
|
|
3181323c1f | ||
|
|
ecf84150c1 | ||
|
|
5b5025c776 | ||
|
|
e47dd3058b | ||
|
|
71f1dcd475 | ||
|
|
941856932c | ||
|
|
c51fde52e7 | ||
|
|
c5362e3bde | ||
|
|
a113703451 | ||
|
|
55ecb918e9 | ||
|
|
3a870e2f8b | ||
|
|
734231a4f1 | ||
|
|
223d6b29f4 | ||
|
|
4f41ec0a97 | ||
|
|
347a53297d | ||
|
|
820766abcb | ||
|
|
4974fa1fed | ||
|
|
7e829fa204 | ||
|
|
f6c7caa48d | ||
|
|
0dd9d252fd | ||
|
|
39f67a241c | ||
|
|
5706b08366 | ||
|
|
81de9695e2 | ||
|
|
589fb25fe3 | ||
|
|
61e5c6b468 | ||
|
|
087ceb3687 | ||
|
|
0a2cd208b2 | ||
|
|
678a936897 | ||
|
|
7c72ca089b | ||
|
|
21530f315f | ||
|
|
7274905a92 | ||
|
|
6c5cff6162 | ||
|
|
cf6b6c129a | ||
|
|
74491d16ae | ||
|
|
c1ab6e4eb4 | ||
|
|
18c9ae235a | ||
|
|
5c69d5fb88 | ||
|
|
90f0bda879 | ||
|
|
1b5c4a21bb | ||
|
|
08ee37112f | ||
|
|
cfbc88d3d6 | ||
|
|
79f5529a5a | ||
|
|
11ed0abd18 | ||
|
|
01830d9910 | ||
|
|
0f573805f2 | ||
|
|
93b1d81a48 | ||
|
|
e28d13b910 | ||
|
|
8731e343c4 | ||
|
|
605eca8cd7 | ||
|
|
5a8ddf5e4a | ||
|
|
f6d5d575fc | ||
|
|
d5c344e3ac | ||
|
|
18ba326cea | ||
|
|
1a1473d3ba | ||
|
|
72804a09ec | ||
|
|
c1ce0a514c | ||
|
|
bd479312b5 | ||
|
|
469da540d2 | ||
|
|
69edaa974f | ||
|
|
ff56963040 | ||
|
|
266aeaef50 | ||
|
|
fc660cfb1f | ||
|
|
27d343bdea | ||
|
|
a04b0da54a | ||
|
|
b15a6bfa98 | ||
|
|
dcc638c12f | ||
|
|
84ea96a5ad | ||
|
|
ae1bf85740 | ||
|
|
1612d713c9 | ||
|
|
6a4a8af731 | ||
|
|
e18375ca6d | ||
|
|
e537e4538a | ||
|
|
1ae97f5477 | ||
|
|
cc0083c6e5 | ||
|
|
43e6ed2da9 | ||
|
|
27bb3a948b | ||
|
|
7c155d307b | ||
|
|
d789beddd0 | ||
|
|
f790148ad3 | ||
|
|
a643abe293 | ||
|
|
099b08f009 | ||
|
|
35ddf6790e | ||
|
|
6502fdb1f5 | ||
|
|
b5cd3bf0af | ||
|
|
8183648902 | ||
|
|
0e1159b01e | ||
|
|
625ef3da8a | ||
|
|
10c7d9a6e1 | ||
|
|
85952ce6b7 | ||
|
|
bf9ce68d8b | ||
|
|
08c5992447 | ||
|
|
dfc7f7c827 | ||
|
|
efdbbc6098 | ||
|
|
185cf90d4c | ||
|
|
4db4790270 | ||
|
|
be3b890e2f | ||
|
|
4536f96493 | ||
|
|
a598c3e7a8 | ||
|
|
d9f5ee9d76 | ||
|
|
a4ced609cd | ||
|
|
673a4e6805 | ||
|
|
d017ccfbd4 | ||
|
|
1f52ed2e83 | ||
|
|
08e83f616c | ||
|
|
51edc4652e | ||
|
|
a3c6f38642 | ||
|
|
a1db53f50b | ||
|
|
9e1046fde3 | ||
|
|
17173f72e0 | ||
|
|
f60a99c357 | ||
|
|
1d763f1bc9 | ||
|
|
248b94c296 | ||
|
|
f52447ff58 | ||
|
|
0cbacbb959 | ||
|
|
a01edecaef | ||
|
|
779756f1ab | ||
|
|
723fedc066 | ||
|
|
a83bb23540 | ||
|
|
5d68a5133e | ||
|
|
8ca629151d | ||
|
|
693965af28 | ||
|
|
e645a350f2 | ||
|
|
85e9808550 | ||
|
|
0ce1c4565e | ||
|
|
478964ad30 | ||
|
|
74a04e3b35 | ||
|
|
a48992ed9d | ||
|
|
9a6ea8c9bb | ||
|
|
51b05cb128 | ||
|
|
de33d6d44c | ||
|
|
3d5cc98df5 | ||
|
|
13f3b54393 | ||
|
|
f17f7b2272 | ||
|
|
f61dc7197a | ||
|
|
0534508bc3 | ||
|
|
446c7ffd6a | ||
|
|
79e6216669 | ||
|
|
5047e48de5 | ||
|
|
bd48112bf9 | ||
|
|
5dc100d900 | ||
|
|
9f2ecb67d4 | ||
|
|
5e4f45826e | ||
|
|
be6ff21184 | ||
|
|
5c660fbe7f | ||
|
|
108718f275 | ||
|
|
ab53a0b403 | ||
|
|
49b815bc98 | ||
|
|
c702814203 | ||
|
|
0c0172a0b6 | ||
|
|
a8266c22f6 | ||
|
|
532c7fbc8f | ||
|
|
23ed381859 | ||
|
|
1ad11b0c58 | ||
|
|
18cca916a0 | ||
|
|
97012082de | ||
|
|
423810cf61 | ||
|
|
a5159ce8e1 | ||
|
|
4dd3952c19 | ||
|
|
1e26b5c5f1 | ||
|
|
67897dfcc0 | ||
|
|
0100604798 | ||
|
|
47afe01721 | ||
|
|
a2e12b795f | ||
|
|
806ab3438e | ||
|
|
f4be90fdd0 | ||
|
|
dd46767ee3 | ||
|
|
a2c712e5b3 | ||
|
|
35f3a0077a | ||
|
|
bc4195942a | ||
|
|
03baca2ed7 | ||
|
|
54a9c31a1a | ||
|
|
db5073223d | ||
|
|
afd766999c | ||
|
|
0637490216 | ||
|
|
6a3ba87b22 | ||
|
|
20b287da52 | ||
|
|
18a378976b | ||
|
|
8e7af49206 | ||
|
|
edeab082d4 | ||
|
|
7b76baaacf | ||
|
|
053365cb67 | ||
|
|
8301120a95 | ||
|
|
f15f0a6226 | ||
|
|
0cfcadf5fa | ||
|
|
435c4acba6 | ||
|
|
edb913855d | ||
|
|
24739e1638 | ||
|
|
54b906addb | ||
|
|
4a7a8df8a4 | ||
|
|
f1dd62c936 | ||
|
|
80cc7b0d64 | ||
|
|
eb4fbf3c0b | ||
|
|
c1cf1206fc | ||
|
|
efebc02d24 | ||
|
|
21dca8c17f | ||
|
|
4eb9839f77 | ||
|
|
3b7906ea04 | ||
|
|
9d17858500 | ||
|
|
d5ceb5f465 | ||
|
|
7dd2a0bbb4 | ||
|
|
13284fb3b9 | ||
|
|
f42c5ec0ce | ||
|
|
6b269839cb | ||
|
|
2eb3e0a278 | ||
|
|
183a437678 | ||
|
|
116b8171f8 | ||
|
|
c8c723bf4a | ||
|
|
d01cf018ce | ||
|
|
c701ab0776 | ||
|
|
180269d6b0 | ||
|
|
645c604fd4 | ||
|
|
de210db90d | ||
|
|
beddf1c772 | ||
|
|
75e618ee4a | ||
|
|
d2a3ba182b | ||
|
|
427f78b14d | ||
|
|
febcf237ca | ||
|
|
5e158c3bd7 | ||
|
|
b4c9c86ba6 | ||
|
|
7c00853f5d | ||
|
|
a0fcb116f5 | ||
|
|
e46b33544d | ||
|
|
6b9c3ad4e7 | ||
|
|
dc12b9a197 | ||
|
|
d473f56c3a | ||
|
|
4138ab3d7d | ||
|
|
e18d1a451d | ||
|
|
a3048cd393 | ||
|
|
dd8fdc6c0a | ||
|
|
9099e4b709 | ||
|
|
52b176b9eb | ||
|
|
69fd70787c | ||
|
|
ff37aea9c8 | ||
|
|
85f73977bf | ||
|
|
2c04ed48c2 | ||
|
|
1228754280 | ||
|
|
a43ee054ad | ||
|
|
83bc714739 | ||
|
|
a08390c84a | ||
|
|
8b6eacecfe | ||
|
|
fb96787697 | ||
|
|
9cff77be62 | ||
|
|
0d1643da66 | ||
|
|
5e7027647a | ||
|
|
28f6f09e8f | ||
|
|
332af5d21b | ||
|
|
e187005130 | ||
|
|
0357386f7c | ||
|
|
47f8e5b8c6 | ||
|
|
e95c9d73a1 | ||
|
|
b7174070fe | ||
|
|
dd06a7b62c | ||
|
|
ff9d480b6e | ||
|
|
229ad9108b | ||
|
|
0e332d291a | ||
|
|
180904cdc2 | ||
|
|
0e83f7d807 | ||
|
|
5d7931fcaf | ||
|
|
2e906b0bf5 | ||
|
|
33ae6f12de | ||
|
|
f302c2e154 | ||
|
|
3ee2492382 |
@@ -1,2 +1 @@
|
|||||||
-r src/requirements/py34.txt
|
|
||||||
-r doc/requirements.txt
|
-r doc/requirements.txt
|
||||||
|
|||||||
10
.travis.sh
10
.travis.sh
@@ -15,13 +15,13 @@ if [ "$PRETIX_CONFIG_FILE" == "tests/travis_postgres.cfg" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$1" == "style" ]; then
|
if [ "$1" == "style" ]; then
|
||||||
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt -r src/requirements/py34.txt
|
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt
|
||||||
cd src
|
cd src
|
||||||
flake8 .
|
flake8 .
|
||||||
isort -c -rc -df .
|
isort -c -rc -df .
|
||||||
fi
|
fi
|
||||||
if [ "$1" == "doctests" ]; then
|
if [ "$1" == "doctests" ]; then
|
||||||
XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt -r src/requirements/py34.txt
|
XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt
|
||||||
cd doc
|
cd doc
|
||||||
make doctest
|
make doctest
|
||||||
fi
|
fi
|
||||||
@@ -39,21 +39,21 @@ if [ "$1" == "translation-spelling" ]; then
|
|||||||
potypo
|
potypo
|
||||||
fi
|
fi
|
||||||
if [ "$1" == "tests" ]; then
|
if [ "$1" == "tests" ]; then
|
||||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt pytest-xdist
|
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt pytest-xdist
|
||||||
cd src
|
cd src
|
||||||
python manage.py check
|
python manage.py check
|
||||||
make all compress
|
make all compress
|
||||||
py.test --reruns 5 -n 2 tests
|
py.test --reruns 5 -n 2 tests
|
||||||
fi
|
fi
|
||||||
if [ "$1" == "tests-cov" ]; then
|
if [ "$1" == "tests-cov" ]; then
|
||||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||||
cd src
|
cd src
|
||||||
python manage.py check
|
python manage.py check
|
||||||
make all compress
|
make all compress
|
||||||
coverage run -m py.test --reruns 5 tests && codecov
|
coverage run -m py.test --reruns 5 tests && codecov
|
||||||
fi
|
fi
|
||||||
if [ "$1" == "plugins" ]; then
|
if [ "$1" == "plugins" ]; then
|
||||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||||
cd src
|
cd src
|
||||||
python setup.py develop
|
python setup.py develop
|
||||||
make all compress
|
make all compress
|
||||||
|
|||||||
10
.travis.yml
10
.travis.yml
@@ -18,20 +18,10 @@ matrix:
|
|||||||
env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||||
- python: 3.6
|
- python: 3.6
|
||||||
env: JOB=style
|
env: JOB=style
|
||||||
- python: 3.4
|
|
||||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
|
|
||||||
- python: 3.5
|
- python: 3.5
|
||||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
|
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
|
||||||
- python: 3.4
|
|
||||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
|
||||||
- python: 3.5
|
|
||||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
|
||||||
- python: 3.6
|
- python: 3.6
|
||||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||||
- python: 3.4
|
|
||||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
|
||||||
- python: 3.5
|
|
||||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
|
||||||
- python: 3.6
|
- python: 3.6
|
||||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||||
- python: 3.6
|
- python: 3.6
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ Example::
|
|||||||
A comma-separated list of plugins that are enabled by default for all new events.
|
A comma-separated list of plugins that are enabled by default for all new events.
|
||||||
Defaults to ``pretix.plugins.sendmail,pretix.plugins.statistics``.
|
Defaults to ``pretix.plugins.sendmail,pretix.plugins.statistics``.
|
||||||
|
|
||||||
|
``plugins_exclude``
|
||||||
|
A comma-separated list of plugins that are not available even though they are installed.
|
||||||
|
Defaults to an empty string.
|
||||||
|
|
||||||
``cookie_domain``
|
``cookie_domain``
|
||||||
The cookie domain to be set. Defaults to ``None``.
|
The cookie domain to be set. Defaults to ``None``.
|
||||||
|
|
||||||
|
|||||||
@@ -121,8 +121,7 @@ command if you're running PostgreSQL::
|
|||||||
|
|
||||||
(venv)$ pip3 install "pretix[mysql]" gunicorn
|
(venv)$ pip3 install "pretix[mysql]" gunicorn
|
||||||
|
|
||||||
If you are running Python 3.4, you also need to ``pip3 install typing``. This is not required on 3.5 or newer.
|
Note that you need Python 3.5 or newer. You can find out your Python version using ``python -V``.
|
||||||
You can find out your Python version using ``python -V``.
|
|
||||||
|
|
||||||
We also need to create a data directory::
|
We also need to create a data directory::
|
||||||
|
|
||||||
|
|||||||
258
doc/api/resources/carts.rst
Normal file
258
doc/api/resources/carts.rst
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
.. _rest-carts:
|
||||||
|
|
||||||
|
Cart positions
|
||||||
|
==============
|
||||||
|
|
||||||
|
The API provides limited access to the cart position data model. This API currently only allows creating and deleting
|
||||||
|
cart positions to reserve quota.
|
||||||
|
|
||||||
|
Cart position resource
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
The cart position resource contains the following public fields:
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
Field Type Description
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
id integer Internal ID of the cart position
|
||||||
|
cart_id string Identifier of the cart this belongs to. Needs to end
|
||||||
|
in "@api" for API-created positions.
|
||||||
|
datetime datetime Time of creation
|
||||||
|
expires datetime The cart position will expire at this time and no longer block quota
|
||||||
|
item integer ID of the item
|
||||||
|
variation integer ID of the variation (or ``null``)
|
||||||
|
price money (string) Price of this position
|
||||||
|
attendee_name string Specified attendee name for this position (or ``null``)
|
||||||
|
attendee_email string Specified attendee email address for this position (or ``null``)
|
||||||
|
voucher integer Internal ID of the voucher used for this position (or ``null``)
|
||||||
|
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
|
||||||
|
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
|
||||||
|
answers list of objects Answers to user-defined questions
|
||||||
|
├ question integer Internal ID of the answered question
|
||||||
|
├ answer string Text representation of the answer
|
||||||
|
├ question_identifier string The question's ``identifier`` field
|
||||||
|
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
|
||||||
|
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
.. versionchanged:: 1.17
|
||||||
|
|
||||||
|
This resource has been added.
|
||||||
|
|
||||||
|
|
||||||
|
Cart position endpoints
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/
|
||||||
|
|
||||||
|
Returns a list of API-created cart positions.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/cartpositions/ 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
|
||||||
|
X-Page-Generated: 2017-12-01T10:00:00Z
|
||||||
|
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"cart_id": "XwokV8FojQviD9jhtDzKvHFdlLRNMhlfo3cNjGbuK6MUTQDT@api",
|
||||||
|
"item": 1,
|
||||||
|
"variation": null,
|
||||||
|
"price": "23.00",
|
||||||
|
"attendee_name": null,
|
||||||
|
"attendee_email": null,
|
||||||
|
"voucher": null,
|
||||||
|
"addon_to": null,
|
||||||
|
"subevent": null,
|
||||||
|
"datetime": "2018-06-11T10:00:00Z",
|
||||||
|
"expires": "2018-06-11T10:00:00Z",
|
||||||
|
"includes_tax": true,
|
||||||
|
"answers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||||
|
: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.
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/(id)/
|
||||||
|
|
||||||
|
Returns information on one cart position, identified by its internal ID.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/cartpositions/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,
|
||||||
|
"cart_id": "XwokV8FojQviD9jhtDzKvHFdlLRNMhlfo3cNjGbuK6MUTQDT@api",
|
||||||
|
"item": 1,
|
||||||
|
"variation": null,
|
||||||
|
"price": "23.00",
|
||||||
|
"attendee_name": null,
|
||||||
|
"attendee_email": null,
|
||||||
|
"voucher": null,
|
||||||
|
"addon_to": null,
|
||||||
|
"subevent": null,
|
||||||
|
"datetime": "2018-06-11T10:00:00Z",
|
||||||
|
"expires": "2018-06-11T10:00:00Z",
|
||||||
|
"includes_tax": true,
|
||||||
|
"answers": []
|
||||||
|
}
|
||||||
|
|
||||||
|
: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 position to fetch
|
||||||
|
: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 cart position does not exist.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/
|
||||||
|
|
||||||
|
Creates a new cart position.
|
||||||
|
|
||||||
|
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend.
|
||||||
|
There is a lot that it does not or can not do, and you will need to be careful using it.
|
||||||
|
It allows to bypass many of the restrictions imposed when creating a cart through the
|
||||||
|
regular shop.
|
||||||
|
|
||||||
|
Specifically, this endpoint currently
|
||||||
|
|
||||||
|
* does not validate if products are only to be sold in a specific time frame
|
||||||
|
|
||||||
|
* does not validate if the event's ticket sales are already over or haven't started
|
||||||
|
|
||||||
|
* does not support add-on products at the moment
|
||||||
|
|
||||||
|
* does not check or calculate prices but believes any prices you send
|
||||||
|
|
||||||
|
* does not support the redemption of vouchers
|
||||||
|
|
||||||
|
* does not prevent you from buying items that can only be bought with a voucher
|
||||||
|
|
||||||
|
* does not support file upload questions
|
||||||
|
|
||||||
|
You can supply the following fields of the resource:
|
||||||
|
|
||||||
|
* ``cart_id`` (optional, needs to end in ``@api``)
|
||||||
|
* ``item``
|
||||||
|
* ``variation`` (optional)
|
||||||
|
* ``price``
|
||||||
|
* ``attendee_name`` (optional)
|
||||||
|
* ``attendee_email`` (optional)
|
||||||
|
* ``subevent`` (optional)
|
||||||
|
* ``expires`` (optional)
|
||||||
|
* ``includes_tax`` (optional)
|
||||||
|
* ``answers``
|
||||||
|
|
||||||
|
* ``question``
|
||||||
|
* ``answer``
|
||||||
|
* ``options``
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/cartpositions/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"item": 1,
|
||||||
|
"variation": null,
|
||||||
|
"price": "23.00",
|
||||||
|
"attendee_name": "Peter",
|
||||||
|
"attendee_email": null,
|
||||||
|
"answers": [
|
||||||
|
{
|
||||||
|
"question": 1,
|
||||||
|
"answer": "23",
|
||||||
|
"options": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subevent": null
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 201 Created
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
(Full cart position resource, see above.)
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer of the event to create a position for
|
||||||
|
:param event: The ``slug`` field of the event to create a position for
|
||||||
|
:statuscode 201: no error
|
||||||
|
:statuscode 400: The item could not be created due to invalid submitted data or lack of quota.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
|
||||||
|
order.
|
||||||
|
|
||||||
|
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/(id)/
|
||||||
|
|
||||||
|
Deletes a cart position, identified by its internal ID.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
DELETE /api/v1/organizers/bigevents/events/sampleconf/cartpositions/1/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 204 No Content
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:param id: The ``id`` field of the position to delete
|
||||||
|
: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 cart position does not exist.
|
||||||
@@ -332,6 +332,10 @@ Order position endpoints
|
|||||||
|
|
||||||
The ``.../redeem/`` endpoint has been added.
|
The ``.../redeem/`` endpoint has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.0
|
||||||
|
|
||||||
|
The order positions endpoint has been extended by the filter queries ``voucher`` and ``voucher__code``.
|
||||||
|
|
||||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/
|
||||||
|
|
||||||
Returns a list of all order positions within a given event. The result is the same as
|
Returns a list of all order positions within a given event. The result is the same as
|
||||||
@@ -422,6 +426,8 @@ Order position endpoints
|
|||||||
:query integer addon_to: Only return positions that are add-ons to the position with the given ID.
|
:query integer addon_to: Only return positions that are add-ons to the position with the given ID.
|
||||||
:query integer addon_to__in: Only return positions that are add-ons to one of the positions with the given
|
:query integer addon_to__in: Only return positions that are add-ons to one of the positions with the given
|
||||||
comma-separated IDs.
|
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.
|
||||||
:param organizer: The ``slug`` field of the organizer to fetch
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
:param event: The ``slug`` field of the event to fetch
|
:param event: The ``slug`` field of the event to fetch
|
||||||
:param list: The ID of the check-in list to look for
|
:param list: The ID of the check-in list to look for
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ Resources and endpoints
|
|||||||
vouchers
|
vouchers
|
||||||
checkinlists
|
checkinlists
|
||||||
waitinglist
|
waitinglist
|
||||||
|
carts
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ checkin_attention boolean If ``True``, th
|
|||||||
a product is being scanned.
|
a product is being scanned.
|
||||||
original_price money (string) An original price, shown for comparison, not used
|
original_price money (string) An original price, shown for comparison, not used
|
||||||
for price calculations.
|
for price calculations.
|
||||||
|
require_approval boolean If ``True``, orders with this product will need to be
|
||||||
|
approved by the event organizer before they can be
|
||||||
|
paid.
|
||||||
has_variations boolean Shows whether or not this item has variations.
|
has_variations boolean Shows whether or not this item has variations.
|
||||||
variations list of objects A list with one object for each variation of this item.
|
variations list of objects A list with one object for each variation of this item.
|
||||||
Can be empty. Only writable during creation,
|
Can be empty. Only writable during creation,
|
||||||
@@ -96,7 +99,11 @@ addons list of objects Definition of a
|
|||||||
|
|
||||||
.. versionchanged:: 1.16
|
.. versionchanged:: 1.16
|
||||||
|
|
||||||
The field ``internal_name`` and ``original_price`` fields have been added.
|
The ``internal_name`` and ``original_price`` fields have been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.0
|
||||||
|
|
||||||
|
The field ``require_approval`` has been added.
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
-----
|
-----
|
||||||
@@ -160,6 +167,7 @@ Endpoints
|
|||||||
"max_per_order": null,
|
"max_per_order": null,
|
||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
"has_variations": false,
|
"has_variations": false,
|
||||||
|
"require_approval": false,
|
||||||
"variations": [
|
"variations": [
|
||||||
{
|
{
|
||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
@@ -244,6 +252,7 @@ Endpoints
|
|||||||
"max_per_order": null,
|
"max_per_order": null,
|
||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
"has_variations": false,
|
"has_variations": false,
|
||||||
|
"require_approval": false,
|
||||||
"variations": [
|
"variations": [
|
||||||
{
|
{
|
||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
@@ -308,6 +317,7 @@ Endpoints
|
|||||||
"min_per_order": null,
|
"min_per_order": null,
|
||||||
"max_per_order": null,
|
"max_per_order": null,
|
||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
|
"require_approval": false,
|
||||||
"variations": [
|
"variations": [
|
||||||
{
|
{
|
||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
@@ -361,6 +371,7 @@ Endpoints
|
|||||||
"max_per_order": null,
|
"max_per_order": null,
|
||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
"has_variations": true,
|
"has_variations": true,
|
||||||
|
"require_approval": false,
|
||||||
"variations": [
|
"variations": [
|
||||||
{
|
{
|
||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
@@ -445,6 +456,7 @@ Endpoints
|
|||||||
"max_per_order": null,
|
"max_per_order": null,
|
||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
"has_variations": true,
|
"has_variations": true,
|
||||||
|
"require_approval": false,
|
||||||
"variations": [
|
"variations": [
|
||||||
{
|
{
|
||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ email string The customer em
|
|||||||
locale string The locale used for communication with this customer
|
locale string The locale used for communication with this customer
|
||||||
datetime datetime Time of order creation
|
datetime datetime Time of order creation
|
||||||
expires datetime The order will expire, if it is still pending by this time
|
expires datetime The order will expire, if it is still pending by this time
|
||||||
payment_date date Date of payment receipt
|
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
|
||||||
payment_provider string Payment provider used for this order
|
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order
|
||||||
total money (string) Total value of this order
|
total money (string) Total value of this order
|
||||||
comment string Internal comment on this order
|
comment string Internal comment on this order
|
||||||
checkin_attention boolean If ``True``, the check-in app should show a warning
|
checkin_attention boolean If ``True``, the check-in app should show a warning
|
||||||
@@ -74,6 +74,12 @@ downloads list of objects List of ticket
|
|||||||
download options.
|
download options.
|
||||||
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
|
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
|
||||||
└ url string Download URL
|
└ url string Download URL
|
||||||
|
require_approval boolean If ``True`` and the order is pending, this order
|
||||||
|
needs approval by an organizer before it can
|
||||||
|
continue. If ``True`` and the order is canceled,
|
||||||
|
this order has been denied by the event organizer.
|
||||||
|
payments list of objects List of payment processes (see below)
|
||||||
|
refunds list of objects List of refund processes (see below)
|
||||||
last_modified datetime Last modification of this object
|
last_modified datetime Last modification of this object
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
@@ -108,6 +114,12 @@ last_modified datetime Last modificati
|
|||||||
The attributes ``order.last_modified`` as well as the corresponding filters to the resource have been added.
|
The attributes ``order.last_modified`` as well as the corresponding filters to the resource have been added.
|
||||||
An endpoint for order creation as well as ``…/mark_refunded/`` has been added.
|
An endpoint for order creation as well as ``…/mark_refunded/`` has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.0
|
||||||
|
|
||||||
|
The ``order.payment_date`` and ``order.payment_provider`` attributes have been deprecated in favor of the new
|
||||||
|
nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. The ``require_approval``
|
||||||
|
attribute has been added, as have been the ``…/approve/`` and ``…/deny/`` endpoints.
|
||||||
|
|
||||||
.. _order-position-resource:
|
.. _order-position-resource:
|
||||||
|
|
||||||
Order position resource
|
Order position resource
|
||||||
@@ -167,9 +179,53 @@ pdf_data object Data object req
|
|||||||
|
|
||||||
The attributes ``pseudonymization_id`` and ``pdf_data`` have been added.
|
The attributes ``pseudonymization_id`` and ``pdf_data`` have been added.
|
||||||
|
|
||||||
|
.. _order-payment-resource:
|
||||||
|
|
||||||
Order endpoints
|
Order payment resource
|
||||||
---------------
|
----------------------
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
Field Type Description
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
local_id integer Internal ID of this payment, starts at 1 for every order
|
||||||
|
state string Payment state, one of ``created``, ``pending``, ``confirmed``, ``canceled``, ``pending``, ``failed``, or ``refunded``
|
||||||
|
amount money (string) Payment amount
|
||||||
|
created datetime Date and time of creation of this payment
|
||||||
|
payment_date datetime Date and time of completion of this payment (or ``null``)
|
||||||
|
provider string Identification string of the payment provider
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
.. versionchanged:: 2.0
|
||||||
|
|
||||||
|
This resource has been added.
|
||||||
|
|
||||||
|
.. _order-payment-resource:
|
||||||
|
|
||||||
|
Order refund resource
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
Field Type Description
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
local_id integer Internal ID of this payment, starts at 1 for every order
|
||||||
|
state string Payment state, one of ``created``, ``transit``, ``external``, ``canceled``, ``failed``, or ``done``
|
||||||
|
source string How this refund has been created, one of ``buyer``, ``admin``, or ``external``
|
||||||
|
amount money (string) Payment amount
|
||||||
|
created datetime Date and time of creation of this payment
|
||||||
|
payment_date datetime Date and time of completion of this payment (or ``null``)
|
||||||
|
provider string Identification string of the payment provider
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
.. versionchanged:: 2.0
|
||||||
|
|
||||||
|
This resource has been added.
|
||||||
|
|
||||||
|
List of all orders
|
||||||
|
------------------
|
||||||
|
|
||||||
.. versionchanged:: 1.15
|
.. versionchanged:: 1.15
|
||||||
|
|
||||||
@@ -216,6 +272,7 @@ Order endpoints
|
|||||||
"total": "23.00",
|
"total": "23.00",
|
||||||
"comment": "",
|
"comment": "",
|
||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
|
"require_approval": false,
|
||||||
"invoice_address": {
|
"invoice_address": {
|
||||||
"last_modified": "2017-12-01T10:00:00Z",
|
"last_modified": "2017-12-01T10:00:00Z",
|
||||||
"is_business": True,
|
"is_business": True,
|
||||||
@@ -275,7 +332,18 @@ Order endpoints
|
|||||||
"output": "pdf",
|
"output": "pdf",
|
||||||
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/download/pdf/"
|
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/download/pdf/"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"payments": [
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"state": "confirmed",
|
||||||
|
"amount": "23.00",
|
||||||
|
"created": "2017-12-01T10:00:00Z",
|
||||||
|
"payment_date": "2017-12-04T12:13:12Z",
|
||||||
|
"provider": "banktransfer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refunds": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -285,6 +353,8 @@ Order endpoints
|
|||||||
``status``. Default: ``datetime``
|
``status``. Default: ``datetime``
|
||||||
:query string code: Only return orders that match the given order code
|
:query string code: Only return orders that match the given order code
|
||||||
:query string status: Only return orders in the given order status (see above)
|
:query string status: Only return orders in the given order status (see above)
|
||||||
|
:query boolean require_approval: If set to ``true`` or ``false``, only categories with this value for the field
|
||||||
|
``require_approval`` will be returned.
|
||||||
:query string email: Only return orders created with the given email address
|
:query string email: Only return orders created with the given email address
|
||||||
:query string locale: Only return orders with the given customer locale
|
:query string locale: Only return orders with the given customer locale
|
||||||
:query datetime modified_since: Only return orders that have changed since the given date
|
:query datetime modified_since: Only return orders that have changed since the given date
|
||||||
@@ -296,6 +366,9 @@ Order endpoints
|
|||||||
:statuscode 401: Authentication failure
|
:statuscode 401: Authentication failure
|
||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
|
|
||||||
|
Fetching individual orders
|
||||||
|
--------------------------
|
||||||
|
|
||||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
|
||||||
|
|
||||||
Returns information on one order, identified by its order code.
|
Returns information on one order, identified by its order code.
|
||||||
@@ -331,6 +404,7 @@ Order endpoints
|
|||||||
"total": "23.00",
|
"total": "23.00",
|
||||||
"comment": "",
|
"comment": "",
|
||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
|
"require_approval": false,
|
||||||
"invoice_address": {
|
"invoice_address": {
|
||||||
"last_modified": "2017-12-01T10:00:00Z",
|
"last_modified": "2017-12-01T10:00:00Z",
|
||||||
"company": "Sample company",
|
"company": "Sample company",
|
||||||
@@ -390,7 +464,18 @@ Order endpoints
|
|||||||
"output": "pdf",
|
"output": "pdf",
|
||||||
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/download/pdf/"
|
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/download/pdf/"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"payments": [
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"state": "confirmed",
|
||||||
|
"amount": "23.00",
|
||||||
|
"created": "2017-12-01T10:00:00Z",
|
||||||
|
"payment_date": "2017-12-04T12:13:12Z",
|
||||||
|
"provider": "banktransfer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refunds": []
|
||||||
}
|
}
|
||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer to fetch
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
@@ -401,6 +486,9 @@ Order endpoints
|
|||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
:statuscode 404: The requested order does not exist.
|
:statuscode 404: The requested order does not exist.
|
||||||
|
|
||||||
|
Order ticket download
|
||||||
|
---------------------
|
||||||
|
|
||||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/download/(output)/
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/download/(output)/
|
||||||
|
|
||||||
Download tickets for an order, identified by its order code. Depending on the chosen output, the response might
|
Download tickets for an order, identified by its order code. Depending on the chosen output, the response might
|
||||||
@@ -442,6 +530,9 @@ Order endpoints
|
|||||||
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||||
seconds.
|
seconds.
|
||||||
|
|
||||||
|
Creating orders
|
||||||
|
---------------
|
||||||
|
|
||||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/
|
||||||
|
|
||||||
Creates a new order.
|
Creates a new order.
|
||||||
@@ -487,18 +578,23 @@ Order endpoints
|
|||||||
|
|
||||||
* ``code`` (optional)
|
* ``code`` (optional)
|
||||||
* ``status`` (optional) – Defaults to pending for non-free orders and paid for free orders. You can only set this to
|
* ``status`` (optional) – Defaults to pending for non-free orders and paid for free orders. You can only set this to
|
||||||
``"n"`` for pending or ``"p"`` for paid. If you create a paid order, the ``order_paid`` signal will **not** be
|
``"n"`` for pending or ``"p"`` for paid. We will create a payment object for this order either in state ``created``
|
||||||
sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and then call
|
or in state ``confirmed``, depending on this value. If you create a paid order, the ``order_paid`` signal will
|
||||||
the ``mark_paid`` API method.
|
**not** be sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and
|
||||||
|
then call the ``mark_paid`` API method.
|
||||||
|
* ``consume_carts`` (optional) – A list of cart IDs. All cart positions with these IDs will be deleted if the
|
||||||
|
order creation is successful. Any quotas that become free by this operation will be credited to your order
|
||||||
|
creation.
|
||||||
* ``email``
|
* ``email``
|
||||||
* ``locale``
|
* ``locale``
|
||||||
* ``payment_provider`` – The identifier of the payment provider set for this order. This needs to be an existing
|
* ``payment_provider`` – The identifier of the payment provider set for this order. This needs to be an existing
|
||||||
payment provider. You should use ``"free"`` for free orders.
|
payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` for all
|
||||||
* ``payment_info`` (optional) – You can pass a nested JSON object that will be set as the internal ``payment_info``
|
orders you create as paid.
|
||||||
value of the order. How this value is handled is up to the payment provider and you should only use this if you
|
* ``payment_info`` (optional) – You can pass a nested JSON object that will be set as the internal ``info``
|
||||||
know the specific payment provider in detail. Please keep in mind that the payment provider will not be called
|
value of the payment object that will be created. How this value is handled is up to the payment provider and you
|
||||||
to do anything about this (i.e. if you pass a bank account to a debit provider, *no* charge will be created),
|
should only use this if you know the specific payment provider in detail. Please keep in mind that the payment
|
||||||
this is just informative in case you *handled the payment already*.
|
provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no*
|
||||||
|
charge will be created), this is just informative in case you *handled the payment already*.
|
||||||
* ``comment`` (optional)
|
* ``comment`` (optional)
|
||||||
* ``checkin_attention`` (optional)
|
* ``checkin_attention`` (optional)
|
||||||
* ``invoice_address`` (optional)
|
* ``invoice_address`` (optional)
|
||||||
@@ -580,11 +676,11 @@ Order endpoints
|
|||||||
{
|
{
|
||||||
"positionid": 1,
|
"positionid": 1,
|
||||||
"item": 1,
|
"item": 1,
|
||||||
"variation": None,
|
"variation": null,
|
||||||
"price": "23.00",
|
"price": "23.00",
|
||||||
"attendee_name": "Peter",
|
"attendee_name": "Peter",
|
||||||
"attendee_email": None,
|
"attendee_email": null,
|
||||||
"addon_to": None,
|
"addon_to": null,
|
||||||
"answers": [
|
"answers": [
|
||||||
{
|
{
|
||||||
"question": 1,
|
"question": 1,
|
||||||
@@ -592,7 +688,7 @@ Order endpoints
|
|||||||
"options": []
|
"options": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"subevent": None
|
"subevent": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -615,6 +711,9 @@ Order endpoints
|
|||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
|
||||||
order.
|
order.
|
||||||
|
|
||||||
|
Order state operations
|
||||||
|
----------------------
|
||||||
|
|
||||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_paid/
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_paid/
|
||||||
|
|
||||||
Marks a pending or expired order as successfully paid.
|
Marks a pending or expired order as successfully paid.
|
||||||
@@ -849,9 +948,88 @@ Order endpoints
|
|||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
:statuscode 404: The requested order does not exist.
|
:statuscode 404: The requested order does not exist.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/approve/
|
||||||
|
|
||||||
Order position endpoints
|
Approve an order that is pending approval.
|
||||||
------------------------
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/approve/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "ABC12",
|
||||||
|
"status": "n",
|
||||||
|
"require_approval": false,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to modify
|
||||||
|
:param event: The ``slug`` field of the event to modify
|
||||||
|
:param code: The ``code`` field of the order to modify
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: The order cannot be approved, likely because the current order status does not allow it.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
|
:statuscode 404: The requested order does not exist.
|
||||||
|
:statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/deny/
|
||||||
|
|
||||||
|
Marks an order that is pending approval as denied.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/deny/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: text/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"send_email": true,
|
||||||
|
"comment": "You're not a business customer!"
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "ABC12",
|
||||||
|
"status": "c",
|
||||||
|
"require_approval": true,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to modify
|
||||||
|
:param event: The ``slug`` field of the event to modify
|
||||||
|
:param code: The ``code`` field of the order to modify
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: The order cannot be marked as denied since the current order status does not allow it.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
|
:statuscode 404: The requested order does not exist.
|
||||||
|
|
||||||
|
|
||||||
|
List of all order positions
|
||||||
|
---------------------------
|
||||||
|
|
||||||
.. versionchanged:: 1.15
|
.. versionchanged:: 1.15
|
||||||
|
|
||||||
@@ -859,6 +1037,11 @@ Order position endpoints
|
|||||||
``order__status__in``, ``subevent__in``, ``addon_to__in`` and ``search``. The search for attendee names and order
|
``order__status__in``, ``subevent__in``, ``addon_to__in`` and ``search``. The search for attendee names and order
|
||||||
codes is now case-insensitive.
|
codes is now case-insensitive.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.0
|
||||||
|
|
||||||
|
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
|
||||||
|
``pseudonymization_id``.
|
||||||
|
|
||||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
|
||||||
|
|
||||||
Returns a list of all order positions within a given event.
|
Returns a list of all order positions within a given event.
|
||||||
@@ -940,6 +1123,7 @@ Order position endpoints
|
|||||||
:query string attendee_name: Only return positions with the given value in the attendee_name field. Also, add-on
|
:query string attendee_name: Only return positions with the given value in the attendee_name field. Also, add-on
|
||||||
products positions are shown if they refer to an attendee with the given name.
|
products positions are shown if they refer to an attendee with the given name.
|
||||||
:query string secret: Only return positions with the given ticket secret.
|
:query string secret: Only return positions with the given ticket secret.
|
||||||
|
:query string pseudonymization_id: Only return positions with the given pseudonymization ID.
|
||||||
:query string order__status: Only return positions with the given order status.
|
:query string order__status: Only return positions with the given order status.
|
||||||
:query string order__status__in: Only return positions with one the given comma-separated order status.
|
:query string order__status__in: Only return positions with one the given comma-separated order status.
|
||||||
:query boolean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been
|
:query boolean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been
|
||||||
@@ -949,12 +1133,17 @@ Order position endpoints
|
|||||||
:query integer addon_to: Only return positions that are add-ons to the position with the given ID.
|
:query integer addon_to: Only return positions that are add-ons to the position with the given ID.
|
||||||
:query integer addon_to__in: Only return positions that are add-ons to one of the positions with the given
|
:query integer addon_to__in: Only return positions that are add-ons to one of the positions with the given
|
||||||
comma-separated IDs.
|
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.
|
||||||
:param organizer: The ``slug`` field of the organizer to fetch
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
:param event: The ``slug`` field of the event to fetch
|
:param event: The ``slug`` field of the event to fetch
|
||||||
:statuscode 200: no error
|
:statuscode 200: no error
|
||||||
:statuscode 401: Authentication failure
|
:statuscode 401: Authentication failure
|
||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
|
|
||||||
|
Fetching individual positions
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
||||||
|
|
||||||
Returns information on one order position, identified by its internal ID.
|
Returns information on one order position, identified by its internal ID.
|
||||||
@@ -1023,6 +1212,9 @@ Order position endpoints
|
|||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
:statuscode 404: The requested order position does not exist.
|
:statuscode 404: The requested order position does not exist.
|
||||||
|
|
||||||
|
Order position ticket download
|
||||||
|
------------------------------
|
||||||
|
|
||||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/download/(output)/
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/download/(output)/
|
||||||
|
|
||||||
Download tickets for one order position, identified by its internal ID.
|
Download tickets for one order position, identified by its internal ID.
|
||||||
@@ -1064,3 +1256,507 @@ Order position endpoints
|
|||||||
:statuscode 404: The requested order position or download provider does not exist.
|
:statuscode 404: The requested order position or download provider does not exist.
|
||||||
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||||
seconds.
|
seconds.
|
||||||
|
|
||||||
|
Manipulating individual positions
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
|
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
||||||
|
|
||||||
|
Deletes an order position, identified by its internal ID.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
DELETE /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 204 No Content
|
||||||
|
Vary: Accept
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:param id: The ``id`` field of the order position to delete
|
||||||
|
:statuscode 204: no error
|
||||||
|
:statuscode 400: This position cannot be deleted (e.g. last position in order)
|
||||||
|
: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 payment endpoints
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
.. versionchanged:: 2.0
|
||||||
|
|
||||||
|
These endpoints have been added.
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/
|
||||||
|
|
||||||
|
Returns a list of all payments for an order.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/ 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": [
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"state": "confirmed",
|
||||||
|
"amount": "23.00",
|
||||||
|
"created": "2017-12-01T10:00:00Z",
|
||||||
|
"payment_date": "2017-12-04T12:13:12Z",
|
||||||
|
"provider": "banktransfer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
: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
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:param order: The ``code`` field of the order to fetch
|
||||||
|
: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 does not exist.
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/(local_id)/
|
||||||
|
|
||||||
|
Returns information on one payment, identified by its order-local ID.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/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
|
||||||
|
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"state": "confirmed",
|
||||||
|
"amount": "23.00",
|
||||||
|
"created": "2017-12-01T10:00:00Z",
|
||||||
|
"payment_date": "2017-12-04T12:13:12Z",
|
||||||
|
"provider": "banktransfer"
|
||||||
|
}
|
||||||
|
|
||||||
|
: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
|
||||||
|
:param local_id: The ``local_id`` field of the payment to fetch
|
||||||
|
: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 or payment does not exist.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/(local_id)/confirm/
|
||||||
|
|
||||||
|
Marks a payment as confirmed. Only allowed in states ``pending`` and ``created``.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/1/confirm/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"force": false}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"state": "confirmed",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
: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
|
||||||
|
:param local_id: The ``local_id`` field of the payment to modify
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: Invalid request or payment state
|
||||||
|
: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 or payment does not exist.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/(local_id)/cancel/
|
||||||
|
|
||||||
|
Marks a payment as canceled. Only allowed in states ``pending`` and ``created``.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/1/cancel/ 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
|
||||||
|
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"state": "canceled",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
: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
|
||||||
|
:param local_id: The ``local_id`` field of the payment to modify
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: Invalid request or payment state
|
||||||
|
: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 or payment does not exist.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/(local_id)/refund/
|
||||||
|
|
||||||
|
Create and execute a manual refund. Only available in ``confirmed`` state. Returns a refund resource, not
|
||||||
|
a payment resource!
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/1/refund/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"amount": "23.00",
|
||||||
|
"mark_refunded": false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"source": "admin",
|
||||||
|
"state": "done",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
: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
|
||||||
|
:param local_id: The ``local_id`` field of the payment to modify
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: Invalid request, payment state, or operation not supported by the payment provider
|
||||||
|
: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 or payment does not exist.
|
||||||
|
|
||||||
|
|
||||||
|
Order refund endpoints
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
.. versionchanged:: 2.0
|
||||||
|
|
||||||
|
These endpoints have been added.
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/
|
||||||
|
|
||||||
|
Returns a list of all refunds for an order.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/ 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": [
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"state": "done",
|
||||||
|
"source": "admin",
|
||||||
|
"amount": "23.00",
|
||||||
|
"payment": 1,
|
||||||
|
"created": "2017-12-01T10:00:00Z",
|
||||||
|
"execution_date": "2017-12-04T12:13:12Z",
|
||||||
|
"provider": "banktransfer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
: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
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:param order: The ``code`` field of the order to fetch
|
||||||
|
: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 does not exist.
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/
|
||||||
|
|
||||||
|
Returns information on one refund, identified by its order-local ID.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/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
|
||||||
|
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"state": "done",
|
||||||
|
"source": "admin",
|
||||||
|
"amount": "23.00",
|
||||||
|
"payment": 1,
|
||||||
|
"created": "2017-12-01T10:00:00Z",
|
||||||
|
"execution_date": "2017-12-04T12:13:12Z",
|
||||||
|
"provider": "banktransfer"
|
||||||
|
}
|
||||||
|
|
||||||
|
: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
|
||||||
|
:param local_id: The ``local_id`` field of the refund to fetch
|
||||||
|
: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 or refund does not exist.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/
|
||||||
|
|
||||||
|
Creates a refund manually.
|
||||||
|
|
||||||
|
.. warning:: We recommend to only use this endpoint for refunds with payment provider ``manual``. This endpoint also
|
||||||
|
does not check for mismatching amounts etc. Be careful!
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"state": "created",
|
||||||
|
"source": "admin",
|
||||||
|
"amount": "23.00",
|
||||||
|
"payment": 1,
|
||||||
|
"execution_date": null,
|
||||||
|
"provider": "manual",
|
||||||
|
"mark_refunded": false
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 201 Created
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"state": "created",
|
||||||
|
"source": "admin",
|
||||||
|
"amount": "23.00",
|
||||||
|
"payment": 1,
|
||||||
|
"created": "2017-12-01T10:00:00Z",
|
||||||
|
"execution_date": null,
|
||||||
|
"provider": "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
: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
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:param order: The ``code`` field of the order to fetch
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: Invalid data supplied
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
|
:statuscode 404: The requested order does not exist.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/done/
|
||||||
|
|
||||||
|
Marks a refund as completed. Only allowed in states ``transit`` and ``created``.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/1/done/ 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
|
||||||
|
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"state": "done",
|
||||||
|
....
|
||||||
|
}
|
||||||
|
|
||||||
|
: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
|
||||||
|
:param local_id: The ``local_id`` field of the refund to modify
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: Invalid request or refund state
|
||||||
|
: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 or refund does not exist.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/process/
|
||||||
|
|
||||||
|
Acts on an external refund, either marks the order as refunded or pending. Only allowed in state ``external``.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/1/done/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"mark_refunded": false}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"state": "done",
|
||||||
|
....
|
||||||
|
}
|
||||||
|
|
||||||
|
: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
|
||||||
|
:param local_id: The ``local_id`` field of the refund to modify
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: Invalid request or refund state
|
||||||
|
: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 or refund does not exist.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/cancel/
|
||||||
|
|
||||||
|
Marks a refund as canceled. Only allowed in states ``transit``, ``external``, and ``created``.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/1/cancel/ 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
|
||||||
|
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"state": "canceled",
|
||||||
|
....
|
||||||
|
}
|
||||||
|
|
||||||
|
: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
|
||||||
|
:param local_id: The ``local_id`` field of the refund to modify
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: Invalid request or refund state
|
||||||
|
: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 or refund does not exist.
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Similarly, there is ``organizer_permission_required`` and ``OrganizerPermissionR
|
|||||||
event-related views, there is also a signal that allows you to add the view to the event navigation like this::
|
event-related views, there is also a signal that allows you to add the view to the event navigation like this::
|
||||||
|
|
||||||
|
|
||||||
from django.core.urlresolvers import resolve, reverse
|
from django.urls import resolve, reverse
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from pretix.control.signals import nav_event
|
from pretix.control.signals import nav_event
|
||||||
|
|||||||
109
doc/development/api/email.rst
Normal file
109
doc/development/api/email.rst
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
.. highlight:: python
|
||||||
|
:linenothreshold: 5
|
||||||
|
|
||||||
|
Writing an HTML e-mail renderer plugin
|
||||||
|
======================================
|
||||||
|
|
||||||
|
An email renderer class controls how the HTML part of e-mails sent by pretix is built.
|
||||||
|
The creation of such a plugin is very similar to creating an export output.
|
||||||
|
|
||||||
|
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
|
||||||
|
|
||||||
|
Output registration
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
The email HTML renderer API does not make a lot of usage from signals, however, it
|
||||||
|
does use a signal to get a list of all available email renderers. Your plugin
|
||||||
|
should listen for this signal and return the subclass of ``pretix.base.email.BaseHTMLMailRenderer``
|
||||||
|
that we'll provide in this plugin::
|
||||||
|
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from pretix.base.signals import register_html_mail_renderers
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(register_html_mail_renderers, dispatch_uid="renderer_custom")
|
||||||
|
def register_mail_renderers(sender, **kwargs):
|
||||||
|
from .email import MyMailRenderer
|
||||||
|
return MyMailRenderer
|
||||||
|
|
||||||
|
|
||||||
|
The renderer class
|
||||||
|
------------------
|
||||||
|
|
||||||
|
.. class:: pretix.base.email.BaseHTMLMailRenderer
|
||||||
|
|
||||||
|
The central object of each email renderer is the subclass of ``BaseHTMLMailRenderer``.
|
||||||
|
|
||||||
|
.. py:attribute:: BaseHTMLMailRenderer.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:: thumbnail_filename
|
||||||
|
|
||||||
|
This is an abstract attribute, you **must** override this!
|
||||||
|
|
||||||
|
.. autoattribute:: is_available
|
||||||
|
|
||||||
|
.. automethod:: render
|
||||||
|
|
||||||
|
This is an abstract method, you **must** implement this!
|
||||||
|
|
||||||
|
Helper class for template-base renderers
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
The email renderer that ships with pretix is based on Django templates to generate HTML.
|
||||||
|
In case you also want to render emails based on a template, we provided a ready-made base
|
||||||
|
class ``TemplateBasedMailRenderer`` that you can re-use to perform the following steps:
|
||||||
|
|
||||||
|
* Convert the body text and the signature to HTML using our markdown renderer
|
||||||
|
|
||||||
|
* Render the template
|
||||||
|
|
||||||
|
* Call `inlinestyler`_ to convert all ``<style>`` style sheets to inline ``style=""``
|
||||||
|
attributes for better compatibility
|
||||||
|
|
||||||
|
To use it, you just need to implement some variables::
|
||||||
|
|
||||||
|
class ClassicMailRenderer(TemplateBasedMailRenderer):
|
||||||
|
verbose_name = _('pretix default')
|
||||||
|
identifier = 'classic'
|
||||||
|
thumbnail_filename = 'pretixbase/email/thumb.png'
|
||||||
|
template_name = 'pretixbase/email/plainwrapper.html'
|
||||||
|
|
||||||
|
The template is passed the following context variables:
|
||||||
|
|
||||||
|
``site``
|
||||||
|
Name of the pretix installation (``settings.PRETIX_INSTANCE_NAME``)
|
||||||
|
|
||||||
|
``site_url``
|
||||||
|
Root URL of the pretix installation (``settings.SITE_URL``)
|
||||||
|
|
||||||
|
``body``
|
||||||
|
The body as markdown (render with ``{{ body|safe }}``)
|
||||||
|
|
||||||
|
``subject``
|
||||||
|
The email subject
|
||||||
|
|
||||||
|
``color``
|
||||||
|
The primary color of the event
|
||||||
|
|
||||||
|
``event``
|
||||||
|
The ``Event`` object
|
||||||
|
|
||||||
|
``signature`` (optional, only if configured)
|
||||||
|
The body as markdown (render with ``{{ signature|safe }}``)
|
||||||
|
|
||||||
|
``order`` (optional, only if applicable)
|
||||||
|
The ``Order`` object
|
||||||
|
|
||||||
|
.. _inlinestyler: https://pypi.org/project/inlinestyler/
|
||||||
@@ -48,7 +48,8 @@ Backend
|
|||||||
-------
|
-------
|
||||||
|
|
||||||
.. automodule:: pretix.control.signals
|
.. automodule:: pretix.control.signals
|
||||||
:members: nav_event, html_head, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings, order_info, event_settings_widget, oauth_application_registered
|
: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
|
||||||
|
|
||||||
|
|
||||||
.. automodule:: pretix.base.signals
|
.. automodule:: pretix.base.signals
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ Contents:
|
|||||||
exporter
|
exporter
|
||||||
ticketoutput
|
ticketoutput
|
||||||
payment
|
payment
|
||||||
|
payment_2.0
|
||||||
|
email
|
||||||
invoice
|
invoice
|
||||||
shredder
|
shredder
|
||||||
customview
|
customview
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ is very similar to creating an export output.
|
|||||||
|
|
||||||
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
|
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
|
||||||
|
|
||||||
|
.. warning:: We changed our payment provider API a lot in pretix 2.x. Our documentation page on :ref:`payment2.0`
|
||||||
|
might be insightful even if you do not have a payment provider to port, as it outlines the rationale
|
||||||
|
behind the current design.
|
||||||
|
|
||||||
Provider registration
|
Provider registration
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
@@ -31,7 +35,7 @@ that the plugin will provide::
|
|||||||
The provider class
|
The provider class
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
.. class:: pretix.base.payment.BasePaymentProvider
|
.. py:class:: pretix.base.payment.BasePaymentProvider
|
||||||
|
|
||||||
The central object of each payment provider is the subclass of ``BasePaymentProvider``.
|
The central object of each payment provider is the subclass of ``BasePaymentProvider``.
|
||||||
|
|
||||||
@@ -54,58 +58,62 @@ The provider class
|
|||||||
|
|
||||||
This is an abstract attribute, you **must** override this!
|
This is an abstract attribute, you **must** override this!
|
||||||
|
|
||||||
.. autoattribute:: is_enabled
|
.. autoattribute:: public_name
|
||||||
|
|
||||||
.. automethod:: calculate_fee
|
.. autoattribute:: is_enabled
|
||||||
|
|
||||||
.. autoattribute:: settings_form_fields
|
.. autoattribute:: settings_form_fields
|
||||||
|
|
||||||
.. automethod:: settings_content_render
|
.. automethod:: settings_content_render
|
||||||
|
|
||||||
.. automethod:: render_invoice_text
|
.. automethod:: is_allowed
|
||||||
|
|
||||||
.. automethod:: payment_form_render
|
.. automethod:: payment_form_render
|
||||||
|
|
||||||
.. automethod:: payment_form
|
.. automethod:: payment_form
|
||||||
|
|
||||||
.. automethod:: is_allowed
|
|
||||||
|
|
||||||
.. autoattribute:: payment_form_fields
|
.. autoattribute:: payment_form_fields
|
||||||
|
|
||||||
.. automethod:: checkout_prepare
|
|
||||||
|
|
||||||
.. automethod:: payment_is_valid_session
|
.. automethod:: payment_is_valid_session
|
||||||
|
|
||||||
|
.. automethod:: checkout_prepare
|
||||||
|
|
||||||
.. automethod:: checkout_confirm_render
|
.. automethod:: checkout_confirm_render
|
||||||
|
|
||||||
This is an abstract method, you **must** override this!
|
This is an abstract method, you **must** override this!
|
||||||
|
|
||||||
.. automethod:: payment_perform
|
.. automethod:: execute_payment
|
||||||
|
|
||||||
|
.. automethod:: calculate_fee
|
||||||
|
|
||||||
.. automethod:: order_pending_mail_render
|
.. automethod:: order_pending_mail_render
|
||||||
|
|
||||||
.. automethod:: order_pending_render
|
.. automethod:: payment_pending_render
|
||||||
|
|
||||||
This is an abstract method, you **must** override this!
|
.. autoattribute:: abort_pending_allowed
|
||||||
|
|
||||||
|
.. automethod:: render_invoice_text
|
||||||
|
|
||||||
.. automethod:: order_change_allowed
|
.. automethod:: order_change_allowed
|
||||||
|
|
||||||
.. automethod:: order_can_retry
|
.. automethod:: order_can_retry
|
||||||
|
|
||||||
.. automethod:: order_prepare
|
.. automethod:: payment_prepare
|
||||||
|
|
||||||
.. automethod:: order_paid_render
|
.. automethod:: payment_control_render
|
||||||
|
|
||||||
.. automethod:: order_control_render
|
.. automethod:: payment_refund_supported
|
||||||
|
|
||||||
.. automethod:: order_control_refund_render
|
.. automethod:: payment_partial_refund_supported
|
||||||
|
|
||||||
.. automethod:: order_control_refund_perform
|
.. automethod:: execute_refund
|
||||||
|
|
||||||
.. automethod:: is_implicit
|
|
||||||
|
|
||||||
.. automethod:: shred_payment_info
|
.. automethod:: shred_payment_info
|
||||||
|
|
||||||
|
.. autoattribute:: is_implicit
|
||||||
|
|
||||||
|
.. autoattribute:: is_meta
|
||||||
|
|
||||||
|
|
||||||
Additional views
|
Additional views
|
||||||
----------------
|
----------------
|
||||||
|
|||||||
129
doc/development/api/payment_2.0.rst
Normal file
129
doc/development/api/payment_2.0.rst
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
.. highlight:: python
|
||||||
|
:linenothreshold: 5
|
||||||
|
|
||||||
|
.. _`payment2.0`:
|
||||||
|
|
||||||
|
Porting a payment provider from pretix 1.x to pretix 2.x
|
||||||
|
========================================================
|
||||||
|
|
||||||
|
In pretix 2.x, we changed large parts of the payment provider API. This documentation details the changes we made
|
||||||
|
and shows you how you can make an existing pretix 1.x payment provider compatible with pretix 2.x
|
||||||
|
|
||||||
|
Conceptual overview
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
In pretix 1.x, an order was always directly connected to a payment provider for the full life of an order. As long as
|
||||||
|
an order was unpaid, this could still be changed in some cases, but once an order was paid, no changes to the payment
|
||||||
|
provider were possible any more. Additionally, the internal state of orders allowed orders only to be fully paid or
|
||||||
|
not paid at all. This leads to a couple of consequences:
|
||||||
|
|
||||||
|
* Payment-related functions (like "execute payment" or "do a refund") always operated on full orders.
|
||||||
|
|
||||||
|
* Changing the total of an order was basically impossible once an order was paid, since there was no concept of
|
||||||
|
partial payments or partial refunds.
|
||||||
|
|
||||||
|
* Payment provider plugins needed to take complicated steps to detect cases that require human intervention, like e.g.
|
||||||
|
|
||||||
|
* An order has expired, no quota is left to revive it, but a payment has been received
|
||||||
|
|
||||||
|
* A payment has been received for a canceled order
|
||||||
|
|
||||||
|
* A payment has been received for an order that has already been paid with a different payment method
|
||||||
|
|
||||||
|
* An external payment service notified us of a refund/dispute
|
||||||
|
|
||||||
|
We noticed that we copied and repeated large portions of code in all our official payment provider plugins, just
|
||||||
|
to deal with some of these cases.
|
||||||
|
|
||||||
|
* Sometimes, there is the need to mark an order as refunded within pretix, without automatically triggering a refund
|
||||||
|
with an external API. Every payment method needed to implement a user interface for this independently.
|
||||||
|
|
||||||
|
* If a refund was not possible automatically, there was no way user to track which payments actually have been refunded
|
||||||
|
manually and which are still left to do.
|
||||||
|
|
||||||
|
* When the payment with one payment provider failed and the user changed to a different payment provider, all
|
||||||
|
information about the first payment was lost from the order object and could only be retrieved from order log data,
|
||||||
|
which also made it hard to design a data shredder API to get rid of this data.
|
||||||
|
|
||||||
|
In pretix 2.x, we introduced two new models, :py:class:`OrderPayment <pretix.base.models.OrderPayment>` and
|
||||||
|
:py:class:`OrderRefund <pretix.base.models.OrderRefund>`. Each instance of these is connected to an order and
|
||||||
|
represents one single attempt to pay or refund a specific amount of money. Each one of these has an individual state,
|
||||||
|
can individually fail or succeed, and carries an amount variable that can differ from the order total.
|
||||||
|
|
||||||
|
This has the following advantages:
|
||||||
|
|
||||||
|
* The system can now detect orders that are over- or underpaid, independent of the payment providers in use.
|
||||||
|
|
||||||
|
* Therefore, we can now allow partial payments, partial refunds, and changing paid orders, and automatically detect
|
||||||
|
the cases listed above and notify the user.
|
||||||
|
|
||||||
|
Payment providers now interact with those payment and refund objects more than with orders.
|
||||||
|
|
||||||
|
Your to-do list
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Payment processing
|
||||||
|
""""""""""""""""""
|
||||||
|
|
||||||
|
* The method ``BasePaymentProvider.order_pending_render`` has been removed and replaced by a new
|
||||||
|
``BasePaymentProvider.payment_pending_render(request, payment)`` method that is passed an ``OrderPayment``
|
||||||
|
object instead of an ``Order``.
|
||||||
|
|
||||||
|
* The method ``BasePaymentProvider.payment_form_render`` now receives a new ``total`` parameter.
|
||||||
|
|
||||||
|
* The method ``BasePaymentProvider.payment_perform`` has been removed and replaced by a new method
|
||||||
|
``BasePaymentProvider.execute_payment(request, payment)`` that is passed an ``OrderPayment``
|
||||||
|
object instead of an ``Order``.
|
||||||
|
|
||||||
|
* The function ``pretix.base.services.mark_order_paid`` has been removed, instead call ``payment.confirm()``
|
||||||
|
on a pending ``OrderPayment`` object. If no further payments are required for this order, this will also
|
||||||
|
mark the order as paid automatically. Note that ``payment.confirm()`` can still throw a ``QuotaExceededException``,
|
||||||
|
however it will still mark the payment as complete (not the order!), so you should catch this exception and
|
||||||
|
inform the user, but not abort the transaction.
|
||||||
|
|
||||||
|
* A new property ``BasePaymentProvider.abort_pending_allowed`` has been introduced. Only if set, the user will
|
||||||
|
be able to retry a payment or switch the payment method when the order currently has a payment object in
|
||||||
|
state ``"pending"``. This replaces ``BasePaymentProvider.order_can_retry``, which no longer exists.
|
||||||
|
|
||||||
|
* The methods ``BasePaymentProvider.retry_prepare`` and ``BasePaymentProvider.order_prepare`` have both been
|
||||||
|
replaced by a new method ``BasePaymentProvider.payment_prepare(request, payment)`` that is passed an ``OrderPayment``
|
||||||
|
object instead of an ``Order``. **Keep in mind that this payment object might have an amount property that
|
||||||
|
differs from the order total, if the order is already partially paid.**
|
||||||
|
|
||||||
|
* The method ``BasePaymentProvider.order_paid_render`` has been removed.
|
||||||
|
|
||||||
|
* The method ``BasePaymentProvider.order_control_render`` has been removed and replaced by a new method
|
||||||
|
``BasePaymentProvider.payment_control_render(request, payment)`` that is passed an ``OrderPayment``
|
||||||
|
object instead of an ``Order``.
|
||||||
|
|
||||||
|
* There's no need to manually deal with excess payments or duplicate payments anymore, just setting the ``OrderPayment``
|
||||||
|
methods to the correct state will do the job.
|
||||||
|
|
||||||
|
Creating refunds
|
||||||
|
""""""""""""""""
|
||||||
|
|
||||||
|
* The methods ``BasePaymentProvider.order_control_refund_render`` and ``BasePaymentProvider.order_control_refund_perform``
|
||||||
|
have been removed.
|
||||||
|
|
||||||
|
* Two new boolean methods ``BasePaymentProvider.payment_refund_supported(payment)`` and ``BasePaymentProvider.payment_partial_refund_supported(payment)``
|
||||||
|
have been introduced. They should be set to return ``True`` if and only if the payment API allows to *automatically*
|
||||||
|
transfer the money back to the customer.
|
||||||
|
|
||||||
|
* A new method ``BasePaymentProvider.execute_refund(refund)`` has been introduced. This method is called using a
|
||||||
|
``OrderRefund`` object in ``"created"`` state and is expected to transfer the money back and confirm success with
|
||||||
|
calling ``refund.done()``. This will only ever be called if either ``BasePaymentProvider.payment_refund_supported(payment)``
|
||||||
|
or ``BasePaymentProvider.payment_partial_refund_supported(payment)`` return ``True``.
|
||||||
|
|
||||||
|
Processing external refunds
|
||||||
|
"""""""""""""""""""""""""""
|
||||||
|
|
||||||
|
* If e.g. a webhook API notifies you that a payment has been disputed or refunded with the external API, you are
|
||||||
|
expected to call ``OrderPayment.create_external_refund(self, amount, execution_date, info='{}')`` on this payment.
|
||||||
|
This will create and return an appropriate ``OrderRefund`` object and send out a notification. However, it will not
|
||||||
|
mark the order as refunded, but will ask the event organizer for a decision.
|
||||||
|
|
||||||
|
Data shredders
|
||||||
|
""""""""""""""
|
||||||
|
|
||||||
|
* The method ``BasePaymentProvider.shred_payment_info`` is no longer passed an order, but instead **either**
|
||||||
|
an ``OrderPayment`` **or** an ``OrderRefund``.
|
||||||
@@ -86,6 +86,15 @@ Carts and Orders
|
|||||||
.. autoclass:: pretix.base.models.OrderPosition
|
.. autoclass:: pretix.base.models.OrderPosition
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: pretix.base.models.OrderFee
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: pretix.base.models.OrderPayment
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: pretix.base.models.OrderRefund
|
||||||
|
:members:
|
||||||
|
|
||||||
.. autoclass:: pretix.base.models.CartPosition
|
.. autoclass:: pretix.base.models.CartPosition
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ External Dependencies
|
|||||||
---------------------
|
---------------------
|
||||||
Your should install the following on your system:
|
Your should install the following on your system:
|
||||||
|
|
||||||
* Python 3.4 or newer
|
* Python 3.5 or newer
|
||||||
* ``pip`` for Python 3 (Debian package: ``python3-pip``)
|
* ``pip`` for Python 3 (Debian package: ``python3-pip``)
|
||||||
* ``python-dev`` for Python 3 (Debian package: ``python3-dev``)
|
* ``python-dev`` for Python 3 (Debian package: ``python3-dev``)
|
||||||
* ``libffi`` (Debian package: ``libffi-dev``)
|
* ``libffi`` (Debian package: ``libffi-dev``)
|
||||||
@@ -54,10 +54,6 @@ The first thing you need are all the main application's dependencies::
|
|||||||
cd src/
|
cd src/
|
||||||
pip3 install -r requirements.txt -r requirements/dev.txt
|
pip3 install -r requirements.txt -r requirements/dev.txt
|
||||||
|
|
||||||
If you are working with Python 3.4, you will also need (you can skip this for Python 3.5+)::
|
|
||||||
|
|
||||||
pip3 install -r requirements/py34.txt
|
|
||||||
|
|
||||||
Next, you need to copy the SCSS files from the source folder to the STATIC_ROOT directory::
|
Next, you need to copy the SCSS files from the source folder to the STATIC_ROOT directory::
|
||||||
|
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
@@ -122,13 +118,15 @@ for example, to check for any errors in any staged files when committing::
|
|||||||
export GIT_WORK_TREE=../
|
export GIT_WORK_TREE=../
|
||||||
export GIT_DIR=../.git
|
export GIT_DIR=../.git
|
||||||
source ../env/bin/activate # Adjust to however you activate your virtual environment
|
source ../env/bin/activate # Adjust to however you activate your virtual environment
|
||||||
for file in $(git diff --cached --name-only | grep -E '\.py$')
|
for file in $(git diff --cached --name-only | grep -E '\.py$' | grep -Ev "migrations|mt940\.py|pretix/settings\.py|make_testdata\.py|testutils/settings\.py|tests/settings\.py|pretix/base/models/__init__\.py")
|
||||||
do
|
do
|
||||||
|
echo $file
|
||||||
git show ":$file" | flake8 - --stdin-display-name="$file" || exit 1 # we only want to lint the staged changes, not any un-staged changes
|
git show ":$file" | flake8 - --stdin-display-name="$file" || exit 1 # we only want to lint the staged changes, not any un-staged changes
|
||||||
git show ":$file" | isort -df --check-only - | grep ERROR && exit 1 || true
|
git show ":$file" | isort -df --check-only - | grep ERROR && exit 1 || true
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
This keeps you from accidentally creating commits violating the style guide.
|
This keeps you from accidentally creating commits violating the style guide.
|
||||||
|
|
||||||
Working with mails
|
Working with mails
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ voucher's settings.
|
|||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
|
Disabling the voucher input
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
If you want to disable voucher input in the widget, you can pass the ``disable-vouchers`` attribute::
|
||||||
|
|
||||||
|
<pretix-widget event="https://pretix.eu/demo/democon/" disable-vouchers></pretix-widget>
|
||||||
|
|
||||||
pretix Button
|
pretix Button
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
@@ -136,7 +143,7 @@ resources. Then, instead of the ``pretix-widget`` tag, use the ``pretix-button``
|
|||||||
As you can see, the ``pretix-button`` element takes an additional ``items`` attribute that specifies the items that
|
As you can see, the ``pretix-button`` element takes an additional ``items`` attribute that specifies the items that
|
||||||
should be added to the cart. The syntax of this attribute is ``item_ITEMID=1,item_ITEMID=2,variation_ITEMID_VARID=4``
|
should be added to the cart. The syntax of this attribute is ``item_ITEMID=1,item_ITEMID=2,variation_ITEMID_VARID=4``
|
||||||
where ``ITEMID`` are the internal IDs of items to be added and ``VARID`` are the internal IDs of variations of those
|
where ``ITEMID`` are the internal IDs of items to be added and ``VARID`` are the internal IDs of variations of those
|
||||||
items, if the items have variations.
|
items, if the items have variations. If you omit the ``items`` attribute, the general start page will be presented.
|
||||||
|
|
||||||
Just as the widget, the button supports the optional attributes ``voucher`` and ``skip-ssl-check``.
|
Just as the widget, the button supports the optional attributes ``voucher`` and ``skip-ssl-check``.
|
||||||
|
|
||||||
|
|||||||
6
readthedocs.yml
Normal file
6
readthedocs.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
build:
|
||||||
|
image: latest
|
||||||
|
|
||||||
|
python:
|
||||||
|
version: 3.6
|
||||||
@@ -8,6 +8,8 @@ recursive-include pretix/control/templates *
|
|||||||
recursive-include pretix/presale/templates *
|
recursive-include pretix/presale/templates *
|
||||||
recursive-include pretix/plugins/banktransfer/templates *
|
recursive-include pretix/plugins/banktransfer/templates *
|
||||||
recursive-include pretix/plugins/banktransfer/static *
|
recursive-include pretix/plugins/banktransfer/static *
|
||||||
|
recursive-include pretix/plugins/manualpayment/templates *
|
||||||
|
recursive-include pretix/plugins/manualpayment/static *
|
||||||
recursive-include pretix/plugins/paypal/templates *
|
recursive-include pretix/plugins/paypal/templates *
|
||||||
recursive-include pretix/plugins/pretixdroid/templates *
|
recursive-include pretix/plugins/pretixdroid/templates *
|
||||||
recursive-include pretix/plugins/pretixdroid/static *
|
recursive-include pretix/plugins/pretixdroid/static *
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.16.0"
|
__version__ = "2.0.0"
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class Migration(migrations.Migration):
|
|||||||
('updated', models.DateTimeField(auto_now=True)),
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
('name', models.CharField(max_length=255, verbose_name='Application name')),
|
('name', models.CharField(max_length=255, verbose_name='Application name')),
|
||||||
('redirect_uris', models.TextField(help_text='Allowed URIs list, space separated',
|
('redirect_uris', models.TextField(help_text='Allowed URIs list, space separated',
|
||||||
validators=[oauth2_provider.validators.validate_uris],
|
validators=[oauth2_provider.validators.URIValidator],
|
||||||
verbose_name='Redirection URIs')),
|
verbose_name='Redirection URIs')),
|
||||||
('client_id',
|
('client_id',
|
||||||
models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100,
|
models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100,
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ from oauth2_provider.models import (
|
|||||||
AbstractAccessToken, AbstractApplication, AbstractGrant,
|
AbstractAccessToken, AbstractApplication, AbstractGrant,
|
||||||
AbstractRefreshToken,
|
AbstractRefreshToken,
|
||||||
)
|
)
|
||||||
from oauth2_provider.validators import validate_uris
|
from oauth2_provider.validators import URIValidator
|
||||||
|
|
||||||
|
|
||||||
class OAuthApplication(AbstractApplication):
|
class OAuthApplication(AbstractApplication):
|
||||||
name = models.CharField(verbose_name=_("Application name"), max_length=255, blank=False)
|
name = models.CharField(verbose_name=_("Application name"), max_length=255, blank=False)
|
||||||
redirect_uris = models.TextField(
|
redirect_uris = models.TextField(
|
||||||
blank=False, validators=[validate_uris],
|
blank=False, validators=[URIValidator],
|
||||||
verbose_name=_("Redirection URIs"),
|
verbose_name=_("Redirection URIs"),
|
||||||
help_text=_("Allowed URIs list, space separated")
|
help_text=_("Allowed URIs list, space separated")
|
||||||
)
|
)
|
||||||
|
|||||||
121
src/pretix/api/serializers/cart.py
Normal file
121
src/pretix/api/serializers/cart.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from django.utils.translation import ugettext_lazy
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
|
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||||
|
from pretix.api.serializers.order import (
|
||||||
|
AnswerCreateSerializer, AnswerSerializer,
|
||||||
|
)
|
||||||
|
from pretix.base.models import Quota
|
||||||
|
from pretix.base.models.orders import CartPosition
|
||||||
|
|
||||||
|
|
||||||
|
class CartPositionSerializer(I18nAwareModelSerializer):
|
||||||
|
answers = AnswerSerializer(many=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CartPosition
|
||||||
|
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
|
||||||
|
'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
|
||||||
|
'answers',)
|
||||||
|
|
||||||
|
|
||||||
|
class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||||
|
answers = AnswerCreateSerializer(many=True, required=False)
|
||||||
|
expires = serializers.DateTimeField(required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CartPosition
|
||||||
|
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
|
||||||
|
'subevent', 'expires', 'includes_tax', 'answers',)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
answers_data = validated_data.pop('answers')
|
||||||
|
if not validated_data.get('cart_id'):
|
||||||
|
cid = "{}@api".format(get_random_string(48))
|
||||||
|
while CartPosition.objects.filter(cart_id=cid).exists():
|
||||||
|
cid = "{}@api".format(get_random_string(48))
|
||||||
|
validated_data['cart_id'] = cid
|
||||||
|
|
||||||
|
if not validated_data.get('expires'):
|
||||||
|
validated_data['expires'] = now() + timedelta(
|
||||||
|
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.context['event'].lock():
|
||||||
|
new_quotas = (validated_data.get('variation').quotas.filter(subevent=validated_data.get('subevent'))
|
||||||
|
if validated_data.get('variation')
|
||||||
|
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
|
||||||
|
if len(new_quotas) == 0:
|
||||||
|
raise ValidationError(
|
||||||
|
ugettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||||
|
str(validated_data.get('item'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for quota in new_quotas:
|
||||||
|
avail = quota.availability()
|
||||||
|
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
|
||||||
|
raise ValidationError(
|
||||||
|
ugettext_lazy('There is not enough quota available on quota "{}" to perform '
|
||||||
|
'the operation.').format(
|
||||||
|
quota.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||||
|
|
||||||
|
for answ_data in answers_data:
|
||||||
|
options = answ_data.pop('options')
|
||||||
|
answ = cp.answers.create(**answ_data)
|
||||||
|
answ.options.add(*options)
|
||||||
|
return cp
|
||||||
|
|
||||||
|
def validate_cart_id(self, cid):
|
||||||
|
if cid and not cid.endswith('@api'):
|
||||||
|
raise ValidationError('Cart ID should end in @api or be empty.')
|
||||||
|
|
||||||
|
def validate_item(self, item):
|
||||||
|
if item.event != self.context['event']:
|
||||||
|
raise ValidationError(
|
||||||
|
'The specified item does not belong to this event.'
|
||||||
|
)
|
||||||
|
if not item.active:
|
||||||
|
raise ValidationError(
|
||||||
|
'The specified item is not active.'
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def validate_subevent(self, subevent):
|
||||||
|
if self.context['event'].has_subevents:
|
||||||
|
if not subevent:
|
||||||
|
raise ValidationError(
|
||||||
|
'You need to set a subevent.'
|
||||||
|
)
|
||||||
|
if subevent.event != self.context['event']:
|
||||||
|
raise ValidationError(
|
||||||
|
'The specified subevent does not belong to this event.'
|
||||||
|
)
|
||||||
|
elif subevent:
|
||||||
|
raise ValidationError(
|
||||||
|
'You cannot set a subevent for this event.'
|
||||||
|
)
|
||||||
|
return subevent
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
if data.get('item'):
|
||||||
|
if data.get('item').has_variations:
|
||||||
|
if not data.get('variation'):
|
||||||
|
raise ValidationError('You should specify a variation for this item.')
|
||||||
|
else:
|
||||||
|
if data.get('variation').item != data.get('item'):
|
||||||
|
raise ValidationError(
|
||||||
|
'The specified variation does not belong to the specified item.'
|
||||||
|
)
|
||||||
|
elif data.get('variation'):
|
||||||
|
raise ValidationError(
|
||||||
|
'You cannot specify a variation for this item.'
|
||||||
|
)
|
||||||
|
return data
|
||||||
@@ -79,7 +79,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
|||||||
'position', 'picture', 'available_from', 'available_until',
|
'position', 'picture', 'available_from', 'available_until',
|
||||||
'require_voucher', 'hide_without_voucher', 'allow_cancel',
|
'require_voucher', 'hide_without_voucher', 'allow_cancel',
|
||||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
|
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
|
||||||
'variations', 'addons', 'original_price')
|
'variations', 'addons', 'original_price', 'require_approval')
|
||||||
read_only_fields = ('has_variations', 'picture')
|
read_only_fields = ('has_variations', 'picture')
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
|
|||||||
@@ -3,17 +3,21 @@ from collections import Counter
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
from django.utils.translation import ugettext_lazy
|
||||||
from django_countries.fields import Country
|
from django_countries.fields import Country
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
from rest_framework.relations import SlugRelatedField
|
||||||
from rest_framework.reverse import reverse
|
from rest_framework.reverse import reverse
|
||||||
|
|
||||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
||||||
Question, QuestionAnswer, Quota,
|
Question, QuestionAnswer,
|
||||||
|
)
|
||||||
|
from pretix.base.models.orders import (
|
||||||
|
CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||||
)
|
)
|
||||||
from pretix.base.models.orders import OrderFee
|
|
||||||
from pretix.base.pdf import get_variables
|
from pretix.base.pdf import get_variables
|
||||||
from pretix.base.signals import register_ticket_outputs
|
from pretix.base.signals import register_ticket_outputs
|
||||||
|
|
||||||
@@ -155,23 +159,61 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
|||||||
self.fields.pop('pdf_data')
|
self.fields.pop('pdf_data')
|
||||||
|
|
||||||
|
|
||||||
|
class OrderPaymentTypeField(serializers.Field):
|
||||||
|
# TODO: Remove after pretix 2.2
|
||||||
|
def to_representation(self, instance: Order):
|
||||||
|
t = None
|
||||||
|
for p in instance.payments.all():
|
||||||
|
t = p.provider
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
class OrderPaymentDateField(serializers.DateField):
|
||||||
|
# TODO: Remove after pretix 2.2
|
||||||
|
def to_representation(self, instance: Order):
|
||||||
|
t = None
|
||||||
|
for p in instance.payments.all():
|
||||||
|
t = p.payment_date or t
|
||||||
|
if t:
|
||||||
|
|
||||||
|
return super().to_representation(t.date())
|
||||||
|
|
||||||
|
|
||||||
class OrderFeeSerializer(I18nAwareModelSerializer):
|
class OrderFeeSerializer(I18nAwareModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = OrderFee
|
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')
|
||||||
|
|
||||||
|
|
||||||
|
class OrderPaymentSerializer(I18nAwareModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = OrderPayment
|
||||||
|
fields = ('local_id', 'state', 'amount', 'created', 'payment_date', 'provider')
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRefundSerializer(I18nAwareModelSerializer):
|
||||||
|
payment = SlugRelatedField(slug_field='local_id', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OrderRefund
|
||||||
|
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider')
|
||||||
|
|
||||||
|
|
||||||
class OrderSerializer(I18nAwareModelSerializer):
|
class OrderSerializer(I18nAwareModelSerializer):
|
||||||
invoice_address = InvoiceAddressSerializer()
|
invoice_address = InvoiceAddressSerializer()
|
||||||
positions = OrderPositionSerializer(many=True)
|
positions = OrderPositionSerializer(many=True)
|
||||||
fees = OrderFeeSerializer(many=True)
|
fees = OrderFeeSerializer(many=True)
|
||||||
downloads = OrderDownloadsField(source='*')
|
downloads = OrderDownloadsField(source='*')
|
||||||
|
payments = OrderPaymentSerializer(many=True)
|
||||||
|
refunds = OrderRefundSerializer(many=True)
|
||||||
|
payment_date = OrderPaymentDateField(source='*')
|
||||||
|
payment_provider = OrderPaymentTypeField(source='*')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
||||||
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
||||||
'checkin_attention', 'last_modified')
|
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval')
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -298,15 +340,15 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
|||||||
if data.get('item'):
|
if data.get('item'):
|
||||||
if data.get('item').has_variations:
|
if data.get('item').has_variations:
|
||||||
if not data.get('variation'):
|
if not data.get('variation'):
|
||||||
raise ValidationError('You should specify a variation for this item.')
|
raise ValidationError({'variation': ['You should specify a variation for this item.']})
|
||||||
else:
|
else:
|
||||||
if data.get('variation').item != data.get('item'):
|
if data.get('variation').item != data.get('item'):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
'The specified variation does not belong to the specified item.'
|
{'variation': ['The specified variation does not belong to the specified item.']}
|
||||||
)
|
)
|
||||||
elif data.get('variation'):
|
elif data.get('variation'):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
'You cannot specify a variation for this item.'
|
{'variation': ['You cannot specify a variation for this item.']}
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -340,11 +382,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
comment = serializers.CharField(required=False, allow_blank=True)
|
comment = serializers.CharField(required=False, allow_blank=True)
|
||||||
payment_provider = serializers.CharField(required=True)
|
payment_provider = serializers.CharField(required=True)
|
||||||
payment_info = CompatibleJSONField(required=False)
|
payment_info = CompatibleJSONField(required=False)
|
||||||
|
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment',
|
fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment',
|
||||||
'invoice_address', 'positions', 'checkin_attention', 'payment_info')
|
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts')
|
||||||
|
|
||||||
def validate_payment_provider(self, pp):
|
def validate_payment_provider(self, pp):
|
||||||
if pp not in self.context['event'].get_payment_providers():
|
if pp not in self.context['event'].get_payment_providers():
|
||||||
@@ -367,73 +410,140 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
'An order cannot be empty.'
|
'An order cannot be empty.'
|
||||||
)
|
)
|
||||||
|
errs = [{} for p in data]
|
||||||
if any([p.get('positionid') for p in data]):
|
if any([p.get('positionid') for p in data]):
|
||||||
if not all([p.get('positionid') for p in data]):
|
if not all([p.get('positionid') for p in data]):
|
||||||
raise ValidationError(
|
for i, p in enumerate(data):
|
||||||
'If you set position IDs manually, you need to do so for all positions.'
|
if not p.get('positionid'):
|
||||||
)
|
errs[i]['positionid'] = [
|
||||||
|
'If you set position IDs manually, you need to do so for all positions.'
|
||||||
|
]
|
||||||
|
raise ValidationError(errs)
|
||||||
|
|
||||||
last_non_add_on = None
|
last_non_add_on = None
|
||||||
last_posid = 0
|
last_posid = 0
|
||||||
|
|
||||||
for p in data:
|
for i, p in enumerate(data):
|
||||||
if p['positionid'] != last_posid + 1:
|
if p['positionid'] != last_posid + 1:
|
||||||
raise ValidationError("Position IDs need to be consecutive.")
|
errs[i]['positionid'] = [
|
||||||
|
'Position IDs need to be consecutive.'
|
||||||
|
]
|
||||||
if p.get('addon_to') and p['addon_to'] != last_non_add_on:
|
if p.get('addon_to') and p['addon_to'] != last_non_add_on:
|
||||||
raise ValidationError("If you set addon_to, you need to make sure that the referenced "
|
errs[i]['addon_to'] = [
|
||||||
"position ID exists and is transmitted directly before its add-ons.")
|
"If you set addon_to, you need to make sure that the referenced "
|
||||||
|
"position ID exists and is transmitted directly before its add-ons."
|
||||||
|
]
|
||||||
|
|
||||||
if not p.get('addon_to'):
|
if not p.get('addon_to'):
|
||||||
last_non_add_on = p['positionid']
|
last_non_add_on = p['positionid']
|
||||||
last_posid = p['positionid']
|
last_posid = p['positionid']
|
||||||
|
|
||||||
elif any([p.get('addon_to') for p in data]):
|
elif any([p.get('addon_to') for p in data]):
|
||||||
raise ValidationError("If you set addon_to, you need to specify position IDs manually.")
|
errs = [
|
||||||
|
{'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]}
|
||||||
|
for p in data
|
||||||
|
]
|
||||||
|
|
||||||
|
if any(errs):
|
||||||
|
raise ValidationError(errs)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
fees_data = validated_data.pop('fees') if 'fees' in validated_data else []
|
fees_data = validated_data.pop('fees') if 'fees' in validated_data else []
|
||||||
positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
|
positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
|
||||||
|
payment_provider = validated_data.pop('payment_provider')
|
||||||
|
payment_info = validated_data.pop('payment_info', '{}')
|
||||||
|
|
||||||
if 'invoice_address' in validated_data:
|
if 'invoice_address' in validated_data:
|
||||||
ia = InvoiceAddress(**validated_data.pop('invoice_address'))
|
ia = InvoiceAddress(**validated_data.pop('invoice_address'))
|
||||||
else:
|
else:
|
||||||
ia = None
|
ia = None
|
||||||
|
|
||||||
with self.context['event'].lock():
|
with self.context['event'].lock() as now_dt:
|
||||||
quotadiff = Counter()
|
quotadiff = Counter()
|
||||||
for pos_data in positions_data:
|
|
||||||
|
consume_carts = validated_data.pop('consume_carts', [])
|
||||||
|
delete_cps = []
|
||||||
|
quota_avail_cache = {}
|
||||||
|
if consume_carts:
|
||||||
|
for cp in CartPosition.objects.filter(event=self.context['event'], cart_id__in=consume_carts):
|
||||||
|
quotas = (cp.variation.quotas.filter(subevent=cp.subevent)
|
||||||
|
if cp.variation else cp.item.quotas.filter(subevent=cp.subevent))
|
||||||
|
for quota in quotas:
|
||||||
|
if quota not in quota_avail_cache:
|
||||||
|
quota_avail_cache[quota] = list(quota.availability())
|
||||||
|
if quota_avail_cache[quota][1] is not None:
|
||||||
|
quota_avail_cache[quota][1] += 1
|
||||||
|
if cp.expires > now_dt:
|
||||||
|
quotadiff.subtract(quotas)
|
||||||
|
delete_cps.append(cp)
|
||||||
|
|
||||||
|
errs = [{} for p in positions_data]
|
||||||
|
|
||||||
|
for i, pos_data in enumerate(positions_data):
|
||||||
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
|
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
|
||||||
if pos_data.get('variation')
|
if pos_data.get('variation')
|
||||||
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
|
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
|
||||||
|
if len(new_quotas) == 0:
|
||||||
|
errs[i]['item'] = [ugettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||||
|
str(pos_data.get('item'))
|
||||||
|
)]
|
||||||
|
else:
|
||||||
|
for quota in new_quotas:
|
||||||
|
if quota not in quota_avail_cache:
|
||||||
|
quota_avail_cache[quota] = list(quota.availability())
|
||||||
|
|
||||||
|
if quota_avail_cache[quota][1] is not None:
|
||||||
|
quota_avail_cache[quota][1] -= 1
|
||||||
|
if quota_avail_cache[quota][1] < 0:
|
||||||
|
errs[i]['item'] = [
|
||||||
|
ugettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
|
||||||
|
quota.name
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
quotadiff.update(new_quotas)
|
quotadiff.update(new_quotas)
|
||||||
|
|
||||||
for quota, diff in quotadiff.items():
|
if any(errs):
|
||||||
avail = quota.availability()
|
raise ValidationError({'positions': errs})
|
||||||
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
|
|
||||||
raise ValidationError(
|
|
||||||
'There is not enough quota available on quota "{}" to perform the operation.'.format(
|
|
||||||
quota.name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
order = Order(event=self.context['event'], **validated_data)
|
order = Order(event=self.context['event'], **validated_data)
|
||||||
order.set_expires(subevents=[p['subevent'] for p in positions_data])
|
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
|
||||||
order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00'))
|
order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00'))
|
||||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
|
order.meta_info = "{}"
|
||||||
order.payment_provider = 'free'
|
|
||||||
order.status = Order.STATUS_PAID
|
|
||||||
elif order.payment_provider == "free" and order.total != Decimal('0.00'):
|
|
||||||
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
|
|
||||||
if validated_data.get('status') == Order.STATUS_PAID:
|
|
||||||
order.payment_date = now()
|
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
|
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
|
||||||
|
order.status = Order.STATUS_PAID
|
||||||
|
order.save()
|
||||||
|
order.payments.create(
|
||||||
|
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||||
|
)
|
||||||
|
elif payment_provider == "free" and order.total != Decimal('0.00'):
|
||||||
|
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
|
||||||
|
elif validated_data.get('status') == Order.STATUS_PAID:
|
||||||
|
order.payments.create(
|
||||||
|
amount=order.total,
|
||||||
|
provider=payment_provider,
|
||||||
|
info=payment_info,
|
||||||
|
payment_date=now(),
|
||||||
|
state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||||
|
)
|
||||||
|
elif payment_provider:
|
||||||
|
order.payments.create(
|
||||||
|
amount=order.total,
|
||||||
|
provider=payment_provider,
|
||||||
|
info=payment_info,
|
||||||
|
state=OrderPayment.PAYMENT_STATE_CREATED
|
||||||
|
)
|
||||||
|
|
||||||
if ia:
|
if ia:
|
||||||
ia.order = order
|
ia.order = order
|
||||||
ia.save()
|
ia.save()
|
||||||
pos_map = {}
|
pos_map = {}
|
||||||
for pos_data in positions_data:
|
for pos_data in positions_data:
|
||||||
answers_data = pos_data.pop('answers')
|
answers_data = pos_data.pop('answers', [])
|
||||||
addon_to = pos_data.pop('addon_to')
|
addon_to = pos_data.pop('addon_to', None)
|
||||||
pos = OrderPosition(**pos_data)
|
pos = OrderPosition(**pos_data)
|
||||||
pos.order = order
|
pos.order = order
|
||||||
pos._calculate_tax()
|
pos._calculate_tax()
|
||||||
@@ -442,9 +552,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
pos.save()
|
pos.save()
|
||||||
pos_map[pos.positionid] = pos
|
pos_map[pos.positionid] = pos
|
||||||
for answ_data in answers_data:
|
for answ_data in answers_data:
|
||||||
options = answ_data.pop('options')
|
options = answ_data.pop('options', [])
|
||||||
answ = pos.answers.create(**answ_data)
|
answ = pos.answers.create(**answ_data)
|
||||||
answ.options.add(*options)
|
answ.options.add(*options)
|
||||||
|
|
||||||
|
for cp in delete_cps:
|
||||||
|
cp.delete()
|
||||||
for fee_data in fees_data:
|
for fee_data in fees_data:
|
||||||
f = OrderFee(**fee_data)
|
f = OrderFee(**fee_data)
|
||||||
f.order = order
|
f.order = order
|
||||||
@@ -471,3 +584,27 @@ class InvoiceSerializer(I18nAwareModelSerializer):
|
|||||||
'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines',
|
'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines',
|
||||||
'foreign_currency_display', 'foreign_currency_rate', 'foreign_currency_rate_date',
|
'foreign_currency_display', 'foreign_currency_rate', 'foreign_currency_rate_date',
|
||||||
'internal_reference')
|
'internal_reference')
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRefundCreateSerializer(I18nAwareModelSerializer):
|
||||||
|
payment = serializers.IntegerField(required=False, allow_null=True)
|
||||||
|
provider = serializers.CharField(required=True, allow_null=False, allow_blank=False)
|
||||||
|
info = CompatibleJSONField(required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OrderRefund
|
||||||
|
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info')
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
pid = validated_data.pop('payment', None)
|
||||||
|
if pid:
|
||||||
|
try:
|
||||||
|
p = self.context['order'].payments.get(local_id=pid)
|
||||||
|
except OrderPayment.DoesNotExist:
|
||||||
|
raise ValidationError('Unknown payment ID.')
|
||||||
|
else:
|
||||||
|
p = None
|
||||||
|
|
||||||
|
order = OrderRefund(order=self.context['order'], payment=p, **validated_data)
|
||||||
|
order.save()
|
||||||
|
return order
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class WaitingListSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WaitingListEntry
|
model = WaitingListEntry
|
||||||
fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale', 'subevent')
|
fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale', 'subevent', 'priority')
|
||||||
read_only_fields = ('id', 'created', 'voucher')
|
read_only_fields = ('id', 'created', 'voucher')
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from django.apps import apps
|
|||||||
from django.conf.urls import include, url
|
from django.conf.urls import include, url
|
||||||
from rest_framework import routers
|
from rest_framework import routers
|
||||||
|
|
||||||
|
from pretix.api.views import cart
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
checkin, event, item, oauth, order, organizer, voucher, waitinglist,
|
checkin, event, item, oauth, order, organizer, voucher, waitinglist,
|
||||||
)
|
)
|
||||||
@@ -28,6 +30,7 @@ event_router.register(r'invoices', order.InvoiceViewSet)
|
|||||||
event_router.register(r'taxrules', event.TaxRuleViewSet)
|
event_router.register(r'taxrules', event.TaxRuleViewSet)
|
||||||
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
||||||
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
||||||
|
event_router.register(r'cartpositions', cart.CartPositionViewSet)
|
||||||
|
|
||||||
checkinlist_router = routers.DefaultRouter()
|
checkinlist_router = routers.DefaultRouter()
|
||||||
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet)
|
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet)
|
||||||
@@ -39,6 +42,10 @@ item_router = routers.DefaultRouter()
|
|||||||
item_router.register(r'variations', item.ItemVariationViewSet)
|
item_router.register(r'variations', item.ItemVariationViewSet)
|
||||||
item_router.register(r'addons', item.ItemAddOnViewSet)
|
item_router.register(r'addons', item.ItemAddOnViewSet)
|
||||||
|
|
||||||
|
order_router = routers.DefaultRouter()
|
||||||
|
order_router.register(r'payments', order.PaymentViewSet)
|
||||||
|
order_router.register(r'refunds', order.RefundViewSet)
|
||||||
|
|
||||||
# Force import of all plugins to give them a chance to register URLs with the router
|
# Force import of all plugins to give them a chance to register URLs with the router
|
||||||
for app in apps.get_app_configs():
|
for app in apps.get_app_configs():
|
||||||
if hasattr(app, 'PretixPluginMeta'):
|
if hasattr(app, 'PretixPluginMeta'):
|
||||||
@@ -54,6 +61,7 @@ urlpatterns = [
|
|||||||
include(question_router.urls)),
|
include(question_router.urls)),
|
||||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/checkinlists/(?P<list>[^/]+)/',
|
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/checkinlists/(?P<list>[^/]+)/',
|
||||||
include(checkinlist_router.urls)),
|
include(checkinlist_router.urls)),
|
||||||
|
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/orders/(?P<order>[^/]+)/', include(order_router.urls)),
|
||||||
url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"),
|
url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"),
|
||||||
url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"),
|
url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"),
|
||||||
url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"),
|
url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"),
|
||||||
|
|||||||
46
src/pretix/api/views/cart.py
Normal file
46
src/pretix/api/views/cart.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.filters import OrderingFilter
|
||||||
|
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from pretix.api.serializers.cart import (
|
||||||
|
CartPositionCreateSerializer, CartPositionSerializer,
|
||||||
|
)
|
||||||
|
from pretix.base.models import CartPosition
|
||||||
|
|
||||||
|
|
||||||
|
class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
|
serializer_class = CartPositionSerializer
|
||||||
|
queryset = CartPosition.objects.none()
|
||||||
|
filter_backends = (OrderingFilter,)
|
||||||
|
ordering = ('datetime',)
|
||||||
|
ordering_fields = ('datetime', 'cart_id')
|
||||||
|
lookup_field = 'id'
|
||||||
|
permission = 'can_view_orders'
|
||||||
|
write_permission = 'can_change_orders'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return CartPosition.objects.filter(
|
||||||
|
event=self.request.event,
|
||||||
|
cart_id__endswith="@api"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
ctx['event'] = self.request.event
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
with transaction.atomic():
|
||||||
|
self.perform_create(serializer)
|
||||||
|
cp = serializer.instance
|
||||||
|
serializer = CartPositionSerializer(cp, context=serializer.context)
|
||||||
|
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Count, F, Max, OuterRef, Prefetch, Subquery
|
from django.db.models import Count, F, Max, OuterRef, Prefetch, Subquery
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
@@ -32,7 +33,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
|||||||
serializer_class = CheckinListSerializer
|
serializer_class = CheckinListSerializer
|
||||||
queryset = CheckinList.objects.none()
|
queryset = CheckinList.objects.none()
|
||||||
filter_backends = (DjangoFilterBackend,)
|
filter_backends = (DjangoFilterBackend,)
|
||||||
filter_class = CheckinListFilter
|
filterset_class = CheckinListFilter
|
||||||
permission = 'can_view_orders'
|
permission = 'can_view_orders'
|
||||||
write_permission = 'can_change_event_settings'
|
write_permission = 'can_change_event_settings'
|
||||||
|
|
||||||
@@ -175,13 +176,16 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
filter_class = CheckinOrderPositionFilter
|
filterset_class = CheckinOrderPositionFilter
|
||||||
permission = 'can_view_orders'
|
permission = 'can_view_orders'
|
||||||
write_permission = 'can_change_orders'
|
write_permission = 'can_change_orders'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def checkinlist(self):
|
def checkinlist(self):
|
||||||
return get_object_or_404(CheckinList, event=self.request.event, pk=self.kwargs.get("list"))
|
try:
|
||||||
|
return get_object_or_404(CheckinList, event=self.request.event, pk=self.kwargs.get("list"))
|
||||||
|
except ValueError:
|
||||||
|
raise Http404()
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
cqs = Checkin.objects.filter(
|
cqs = Checkin.objects.filter(
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ReadOnlyModelViewSet):
|
|||||||
serializer_class = SubEventSerializer
|
serializer_class = SubEventSerializer
|
||||||
queryset = ItemCategory.objects.none()
|
queryset = ItemCategory.objects.none()
|
||||||
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
|
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
|
||||||
filter_class = SubEventFilter
|
filterset_class = SubEventFilter
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.event.subevents.prefetch_related(
|
return self.request.event.subevents.prefetch_related(
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||||
ordering_fields = ('id', 'position')
|
ordering_fields = ('id', 'position')
|
||||||
ordering = ('position', 'id')
|
ordering = ('position', 'id')
|
||||||
filter_class = ItemFilter
|
filterset_class = ItemFilter
|
||||||
permission = 'can_change_items'
|
permission = 'can_change_items'
|
||||||
write_permission = 'can_change_items'
|
write_permission = 'can_change_items'
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
serializer_class = ItemCategorySerializer
|
serializer_class = ItemCategorySerializer
|
||||||
queryset = ItemCategory.objects.none()
|
queryset = ItemCategory.objects.none()
|
||||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||||
filter_class = ItemCategoryFilter
|
filterset_class = ItemCategoryFilter
|
||||||
ordering_fields = ('id', 'position')
|
ordering_fields = ('id', 'position')
|
||||||
ordering = ('position', 'id')
|
ordering = ('position', 'id')
|
||||||
permission = 'can_change_items'
|
permission = 'can_change_items'
|
||||||
@@ -261,7 +261,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
serializer_class = QuestionSerializer
|
serializer_class = QuestionSerializer
|
||||||
queryset = Question.objects.none()
|
queryset = Question.objects.none()
|
||||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||||
filter_class = QuestionFilter
|
filterset_class = QuestionFilter
|
||||||
ordering_fields = ('id', 'position')
|
ordering_fields = ('id', 'position')
|
||||||
ordering = ('position', 'id')
|
ordering = ('position', 'id')
|
||||||
permission = 'can_change_items'
|
permission = 'can_change_items'
|
||||||
@@ -359,7 +359,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
serializer_class = QuotaSerializer
|
serializer_class = QuotaSerializer
|
||||||
queryset = Quota.objects.none()
|
queryset = Quota.objects.none()
|
||||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||||
filter_class = QuotaFilter
|
filterset_class = QuotaFilter
|
||||||
ordering_fields = ('id', 'size')
|
ordering_fields = ('id', 'size')
|
||||||
ordering = ('id',)
|
ordering = ('id',)
|
||||||
permission = 'can_change_items'
|
permission = 'can_change_items'
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ from django.db import transaction
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models.functions import Concat
|
from django.db.models.functions import Concat
|
||||||
from django.http import FileResponse
|
from django.http import FileResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.timezone import make_aware, now
|
from django.utils.timezone import make_aware, now
|
||||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||||
from rest_framework import serializers, status, viewsets
|
from rest_framework import mixins, serializers, status, viewsets
|
||||||
from rest_framework.decorators import detail_route
|
from rest_framework.decorators import detail_route
|
||||||
from rest_framework.exceptions import (
|
from rest_framework.exceptions import (
|
||||||
APIException, NotFound, PermissionDenied, ValidationError,
|
APIException, NotFound, PermissionDenied, ValidationError,
|
||||||
@@ -19,20 +20,23 @@ from rest_framework.response import Response
|
|||||||
|
|
||||||
from pretix.api.models import OAuthAccessToken
|
from pretix.api.models import OAuthAccessToken
|
||||||
from pretix.api.serializers.order import (
|
from pretix.api.serializers.order import (
|
||||||
InvoiceSerializer, OrderCreateSerializer, OrderPositionSerializer,
|
InvoiceSerializer, OrderCreateSerializer, OrderPaymentSerializer,
|
||||||
OrderSerializer,
|
OrderPositionSerializer, OrderRefundCreateSerializer,
|
||||||
|
OrderRefundSerializer, OrderSerializer,
|
||||||
)
|
)
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Invoice, Order, OrderPosition, Quota, TeamAPIToken,
|
Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota,
|
||||||
|
TeamAPIToken,
|
||||||
)
|
)
|
||||||
|
from pretix.base.payment import PaymentException
|
||||||
from pretix.base.services.invoices import (
|
from pretix.base.services.invoices import (
|
||||||
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
||||||
regenerate_invoice,
|
regenerate_invoice,
|
||||||
)
|
)
|
||||||
from pretix.base.services.mail import SendMailException
|
from pretix.base.services.mail import SendMailException
|
||||||
from pretix.base.services.orders import (
|
from pretix.base.services.orders import (
|
||||||
OrderError, cancel_order, extend_order, mark_order_expired,
|
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
|
||||||
mark_order_paid, mark_order_refunded,
|
extend_order, mark_order_expired, mark_order_refunded,
|
||||||
)
|
)
|
||||||
from pretix.base.services.tickets import (
|
from pretix.base.services.tickets import (
|
||||||
get_cachedticket_for_order, get_cachedticket_for_position,
|
get_cachedticket_for_order, get_cachedticket_for_position,
|
||||||
@@ -41,14 +45,14 @@ from pretix.base.signals import order_placed, register_ticket_outputs
|
|||||||
|
|
||||||
|
|
||||||
class OrderFilter(FilterSet):
|
class OrderFilter(FilterSet):
|
||||||
email = django_filters.CharFilter(name='email', lookup_expr='iexact')
|
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
|
||||||
code = django_filters.CharFilter(name='code', lookup_expr='iexact')
|
code = django_filters.CharFilter(field_name='code', lookup_expr='iexact')
|
||||||
status = django_filters.CharFilter(name='status', lookup_expr='iexact')
|
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
|
||||||
modified_since = django_filters.IsoDateTimeFilter(name='last_modified', lookup_expr='gte')
|
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
fields = ['code', 'status', 'email', 'locale']
|
fields = ['code', 'status', 'email', 'locale', 'require_approval']
|
||||||
|
|
||||||
|
|
||||||
class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
@@ -57,7 +61,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||||
ordering = ('datetime',)
|
ordering = ('datetime',)
|
||||||
ordering_fields = ('datetime', 'code', 'status')
|
ordering_fields = ('datetime', 'code', 'status')
|
||||||
filter_class = OrderFilter
|
filterset_class = OrderFilter
|
||||||
lookup_field = 'code'
|
lookup_field = 'code'
|
||||||
permission = 'can_view_orders'
|
permission = 'can_view_orders'
|
||||||
write_permission = 'can_change_orders'
|
write_permission = 'can_change_orders'
|
||||||
@@ -70,7 +74,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.event.orders.prefetch_related(
|
return self.request.event.orders.prefetch_related(
|
||||||
'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options',
|
'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options',
|
||||||
'positions__answers__question', 'fees'
|
'positions__answers__question', 'fees', 'payments', 'refunds', 'refunds__payment'
|
||||||
).select_related(
|
).select_related(
|
||||||
'invoice_address'
|
'invoice_address'
|
||||||
)
|
)
|
||||||
@@ -122,14 +126,33 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
order = self.get_object()
|
order = self.get_object()
|
||||||
|
|
||||||
if order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED):
|
if order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED):
|
||||||
|
|
||||||
|
ps = order.pending_sum
|
||||||
try:
|
try:
|
||||||
mark_order_paid(
|
p = order.payments.get(
|
||||||
order, manual=True,
|
state__in=(OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
|
||||||
user=request.user if request.user.is_authenticated else None,
|
provider='manual',
|
||||||
auth=request.auth,
|
amount=ps
|
||||||
)
|
)
|
||||||
|
except OrderPayment.DoesNotExist:
|
||||||
|
order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING,
|
||||||
|
OrderPayment.PAYMENT_STATE_CREATED)) \
|
||||||
|
.update(state=OrderPayment.PAYMENT_STATE_CANCELED)
|
||||||
|
p = order.payments.create(
|
||||||
|
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||||
|
provider='manual',
|
||||||
|
amount=ps,
|
||||||
|
fee=None
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
p.confirm(auth=self.request.auth,
|
||||||
|
user=self.request.user if request.user.is_authenticated else None,
|
||||||
|
count_waitinglist=False)
|
||||||
except Quota.QuotaExceededException as e:
|
except Quota.QuotaExceededException as e:
|
||||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except PaymentException as e:
|
||||||
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -159,6 +182,42 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
)
|
)
|
||||||
return self.retrieve(request, [], **kwargs)
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
|
@detail_route(methods=['POST'])
|
||||||
|
def approve(self, request, **kwargs):
|
||||||
|
send_mail = request.data.get('send_email', True)
|
||||||
|
|
||||||
|
order = self.get_object()
|
||||||
|
try:
|
||||||
|
approve_order(
|
||||||
|
order,
|
||||||
|
user=request.user if request.user.is_authenticated else None,
|
||||||
|
auth=request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken)) else None,
|
||||||
|
send_mail=send_mail,
|
||||||
|
)
|
||||||
|
except Quota.QuotaExceededException as e:
|
||||||
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except OrderError as e:
|
||||||
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
|
@detail_route(methods=['POST'])
|
||||||
|
def deny(self, request, **kwargs):
|
||||||
|
send_mail = request.data.get('send_email', True)
|
||||||
|
comment = request.data.get('comment', '')
|
||||||
|
|
||||||
|
order = self.get_object()
|
||||||
|
try:
|
||||||
|
deny_order(
|
||||||
|
order,
|
||||||
|
user=request.user if request.user.is_authenticated else None,
|
||||||
|
auth=request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken)) else None,
|
||||||
|
send_mail=send_mail,
|
||||||
|
comment=comment,
|
||||||
|
)
|
||||||
|
except OrderError as e:
|
||||||
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
@detail_route(methods=['POST'])
|
@detail_route(methods=['POST'])
|
||||||
def mark_pending(self, request, **kwargs):
|
def mark_pending(self, request, **kwargs):
|
||||||
order = self.get_object()
|
order = self.get_object()
|
||||||
@@ -170,7 +229,6 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
order.status = Order.STATUS_PENDING
|
order.status = Order.STATUS_PENDING
|
||||||
order.payment_manual = True
|
|
||||||
order.save()
|
order.save()
|
||||||
order.log_action(
|
order.log_action(
|
||||||
'pretix.event.order.unpaid',
|
'pretix.event.order.unpaid',
|
||||||
@@ -285,7 +343,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class OrderPositionFilter(FilterSet):
|
class OrderPositionFilter(FilterSet):
|
||||||
order = django_filters.CharFilter(name='order', lookup_expr='code__iexact')
|
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
|
||||||
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
|
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
|
||||||
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
|
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
|
||||||
search = django_filters.CharFilter(method='search_qs')
|
search = django_filters.CharFilter(method='search_qs')
|
||||||
@@ -313,18 +371,22 @@ class OrderPositionFilter(FilterSet):
|
|||||||
'secret': ['exact'],
|
'secret': ['exact'],
|
||||||
'order__status': ['exact', 'in'],
|
'order__status': ['exact', 'in'],
|
||||||
'addon_to': ['exact', 'in'],
|
'addon_to': ['exact', 'in'],
|
||||||
'subevent': ['exact', 'in']
|
'subevent': ['exact', 'in'],
|
||||||
|
'pseudonymization_id': ['exact'],
|
||||||
|
'voucher__code': ['exact'],
|
||||||
|
'voucher': ['exact'],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
serializer_class = OrderPositionSerializer
|
serializer_class = OrderPositionSerializer
|
||||||
queryset = OrderPosition.objects.none()
|
queryset = OrderPosition.objects.none()
|
||||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||||
ordering = ('order__datetime', 'positionid')
|
ordering = ('order__datetime', 'positionid')
|
||||||
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
|
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
|
||||||
filter_class = OrderPositionFilter
|
filterset_class = OrderPositionFilter
|
||||||
permission = 'can_view_orders'
|
permission = 'can_view_orders'
|
||||||
|
write_permission = 'can_change_orders'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related(
|
return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related(
|
||||||
@@ -365,11 +427,232 @@ class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
)
|
)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
try:
|
||||||
|
ocm = OrderChangeManager(
|
||||||
|
instance.order,
|
||||||
|
user=self.request.user if self.request.user.is_authenticated else None,
|
||||||
|
auth=self.request.auth,
|
||||||
|
notify=False
|
||||||
|
)
|
||||||
|
ocm.cancel(instance)
|
||||||
|
ocm.commit()
|
||||||
|
except OrderError as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
except Quota.QuotaExceededException as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
serializer_class = OrderPaymentSerializer
|
||||||
|
queryset = OrderPayment.objects.none()
|
||||||
|
permission = 'can_view_orders'
|
||||||
|
write_permission = 'can_change_orders'
|
||||||
|
lookup_field = 'local_id'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
||||||
|
return order.payments.all()
|
||||||
|
|
||||||
|
@detail_route(methods=['POST'])
|
||||||
|
def confirm(self, request, **kwargs):
|
||||||
|
payment = self.get_object()
|
||||||
|
force = request.data.get('force', False)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment.confirm(user=self.request.user if self.request.user.is_authenticated else None,
|
||||||
|
auth=self.request.auth,
|
||||||
|
count_waitinglist=False,
|
||||||
|
force=force)
|
||||||
|
except Quota.QuotaExceededException as e:
|
||||||
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except PaymentException as e:
|
||||||
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except SendMailException:
|
||||||
|
pass
|
||||||
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
|
@detail_route(methods=['POST'])
|
||||||
|
def refund(self, request, **kwargs):
|
||||||
|
payment = self.get_object()
|
||||||
|
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
||||||
|
request.data.get('amount', str(payment.amount))
|
||||||
|
)
|
||||||
|
mark_refunded = request.data.get('mark_refunded', False)
|
||||||
|
|
||||||
|
if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED:
|
||||||
|
return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
full_refund_possible = payment.payment_provider.payment_refund_supported(payment)
|
||||||
|
partial_refund_possible = payment.payment_provider.payment_partial_refund_supported(payment)
|
||||||
|
available_amount = payment.amount - payment.refunded_amount
|
||||||
|
|
||||||
|
if amount <= 0:
|
||||||
|
return Response({'amount': ['Invalid refund amount.']}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if amount > available_amount:
|
||||||
|
return Response(
|
||||||
|
{'amount': ['Invalid refund amount, only {} are available to refund.'.format(available_amount)]},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if amount != payment.amount and not partial_refund_possible:
|
||||||
|
return Response({'amount': ['Partial refund not available for this payment method.']},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
if amount == payment.amount and not full_refund_possible:
|
||||||
|
return Response({'amount': ['Full refund not available for this payment method.']},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
r = payment.order.refunds.create(
|
||||||
|
payment=payment,
|
||||||
|
source=OrderRefund.REFUND_SOURCE_ADMIN,
|
||||||
|
state=OrderRefund.REFUND_STATE_CREATED,
|
||||||
|
amount=amount,
|
||||||
|
provider=payment.provider
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r.payment_provider.execute_refund(r)
|
||||||
|
except PaymentException as e:
|
||||||
|
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||||
|
r.save()
|
||||||
|
return Response({'detail': 'External error: {}'.format(str(e))},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
else:
|
||||||
|
payment.order.log_action('pretix.event.order.refund.created', {
|
||||||
|
'local_id': r.local_id,
|
||||||
|
'provider': r.provider,
|
||||||
|
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||||
|
if payment.order.pending_sum > 0:
|
||||||
|
if mark_refunded:
|
||||||
|
mark_order_refunded(payment.order,
|
||||||
|
user=self.request.user if self.request.user.is_authenticated else None,
|
||||||
|
auth=self.request.auth)
|
||||||
|
else:
|
||||||
|
payment.order.status = Order.STATUS_PENDING
|
||||||
|
payment.order.set_expires(
|
||||||
|
now(),
|
||||||
|
payment.order.event.subevents.filter(
|
||||||
|
id__in=payment.order.positions.values_list('subevent_id', flat=True))
|
||||||
|
)
|
||||||
|
payment.order.save()
|
||||||
|
return Response(OrderRefundSerializer(r).data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@detail_route(methods=['POST'])
|
||||||
|
def cancel(self, request, **kwargs):
|
||||||
|
payment = self.get_object()
|
||||||
|
|
||||||
|
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)
|
||||||
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
|
serializer_class = OrderRefundSerializer
|
||||||
|
queryset = OrderRefund.objects.none()
|
||||||
|
permission = 'can_view_orders'
|
||||||
|
write_permission = 'can_change_orders'
|
||||||
|
lookup_field = 'local_id'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
||||||
|
return order.refunds.all()
|
||||||
|
|
||||||
|
@detail_route(methods=['POST'])
|
||||||
|
def cancel(self, request, **kwargs):
|
||||||
|
refund = self.get_object()
|
||||||
|
|
||||||
|
if refund.state not in (OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT,
|
||||||
|
OrderRefund.REFUND_STATE_EXTERNAL):
|
||||||
|
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
refund.state = OrderRefund.REFUND_STATE_CANCELED
|
||||||
|
refund.save()
|
||||||
|
refund.order.log_action('pretix.event.order.refund.canceled', {
|
||||||
|
'local_id': refund.local_id,
|
||||||
|
'provider': refund.provider,
|
||||||
|
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||||
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
|
@detail_route(methods=['POST'])
|
||||||
|
def process(self, request, **kwargs):
|
||||||
|
refund = self.get_object()
|
||||||
|
|
||||||
|
if refund.state != OrderRefund.REFUND_STATE_EXTERNAL:
|
||||||
|
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||||
|
if request.data.get('mark_refunded', False):
|
||||||
|
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
|
||||||
|
auth=self.request.auth)
|
||||||
|
else:
|
||||||
|
refund.order.status = Order.STATUS_PENDING
|
||||||
|
refund.order.set_expires(
|
||||||
|
now(),
|
||||||
|
refund.order.event.subevents.filter(
|
||||||
|
id__in=refund.order.positions.values_list('subevent_id', flat=True))
|
||||||
|
)
|
||||||
|
refund.order.save()
|
||||||
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
|
@detail_route(methods=['POST'])
|
||||||
|
def done(self, request, **kwargs):
|
||||||
|
refund = self.get_object()
|
||||||
|
|
||||||
|
if refund.state not in (OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT):
|
||||||
|
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||||
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
ctx['order'] = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
mark_refunded = request.data.pop('mark_refunded', False)
|
||||||
|
serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
with transaction.atomic():
|
||||||
|
self.perform_create(serializer)
|
||||||
|
r = serializer.instance
|
||||||
|
serializer = OrderRefundSerializer(r, context=serializer.context)
|
||||||
|
|
||||||
|
r.order.log_action(
|
||||||
|
'pretix.event.order.refund.created', {
|
||||||
|
'local_id': r.local_id,
|
||||||
|
'provider': r.provider,
|
||||||
|
},
|
||||||
|
user=request.user if request.user.is_authenticated else None,
|
||||||
|
auth=request.auth
|
||||||
|
)
|
||||||
|
if mark_refunded:
|
||||||
|
mark_order_refunded(
|
||||||
|
r.order,
|
||||||
|
user=request.user if request.user.is_authenticated else None,
|
||||||
|
auth=(request.auth if request.auth else None),
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
|
||||||
class InvoiceFilter(FilterSet):
|
class InvoiceFilter(FilterSet):
|
||||||
refers = django_filters.CharFilter(method='refers_qs')
|
refers = django_filters.CharFilter(method='refers_qs')
|
||||||
number = django_filters.CharFilter(method='nr_qs')
|
number = django_filters.CharFilter(method='nr_qs')
|
||||||
order = django_filters.CharFilter(name='order', lookup_expr='code__iexact')
|
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
|
||||||
|
|
||||||
def refers_qs(self, queryset, name, value):
|
def refers_qs(self, queryset, name, value):
|
||||||
return queryset.annotate(
|
return queryset.annotate(
|
||||||
@@ -396,7 +679,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||||
ordering = ('nr',)
|
ordering = ('nr',)
|
||||||
ordering_fields = ('nr', 'date')
|
ordering_fields = ('nr', 'date')
|
||||||
filter_class = InvoiceFilter
|
filterset_class = InvoiceFilter
|
||||||
permission = 'can_view_orders'
|
permission = 'can_view_orders'
|
||||||
lookup_url_kwarg = 'number'
|
lookup_url_kwarg = 'number'
|
||||||
lookup_field = 'nr'
|
lookup_field = 'nr'
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
lookup_url_kwarg = 'organizer'
|
lookup_url_kwarg = 'organizer'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if self.request.user.is_authenticated():
|
if self.request.user.is_authenticated:
|
||||||
if self.request.user.has_active_staff_session(self.request.session.session_key):
|
if self.request.user.has_active_staff_session(self.request.session.session_key):
|
||||||
return Organizer.objects.all()
|
return Organizer.objects.all()
|
||||||
elif isinstance(self.request.auth, OAuthAccessToken):
|
elif isinstance(self.request.auth, OAuthAccessToken):
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
|||||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||||
ordering = ('id',)
|
ordering = ('id',)
|
||||||
ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value')
|
ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value')
|
||||||
filter_class = VoucherFilter
|
filterset_class = VoucherFilter
|
||||||
permission = 'can_view_vouchers'
|
permission = 'can_view_vouchers'
|
||||||
write_permission = 'can_change_vouchers'
|
write_permission = 'can_change_vouchers'
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class WaitingListViewSet(viewsets.ModelViewSet):
|
|||||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||||
ordering = ('created',)
|
ordering = ('created',)
|
||||||
ordering_fields = ('id', 'created', 'email', 'item')
|
ordering_fields = ('id', 'created', 'email', 'item')
|
||||||
filter_class = WaitingListFilter
|
filterset_class = WaitingListFilter
|
||||||
permission = 'can_view_orders'
|
permission = 'can_view_orders'
|
||||||
write_permission = 'can_change_orders'
|
write_permission = 'can_change_orders'
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class PretixBaseConfig(AppConfig):
|
|||||||
from . import exporters # NOQA
|
from . import exporters # NOQA
|
||||||
from . import invoice # NOQA
|
from . import invoice # NOQA
|
||||||
from . import notifications # NOQA
|
from . import notifications # NOQA
|
||||||
|
from . import email # NOQA
|
||||||
from .services import auth, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA
|
from .services import auth, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import logging
|
import logging
|
||||||
from smtplib import SMTPRecipientsRefused, SMTPSenderRefused
|
from smtplib import SMTPRecipientsRefused, SMTPSenderRefused
|
||||||
|
|
||||||
|
import bleach
|
||||||
|
import markdown
|
||||||
|
from django.conf import settings
|
||||||
from django.core.mail.backends.smtp import EmailBackend
|
from django.core.mail.backends.smtp import EmailBackend
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.template.loader import get_template
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from inlinestyler.utils import inline_css
|
||||||
|
|
||||||
|
from pretix.base.models import Event, Order
|
||||||
|
from pretix.base.signals import register_html_mail_renderers
|
||||||
|
from pretix.base.templatetags.rich_text import markdown_compile
|
||||||
|
|
||||||
logger = logging.getLogger('pretix.base.email')
|
logger = logging.getLogger('pretix.base.email')
|
||||||
|
|
||||||
@@ -24,3 +35,103 @@ class CustomSMTPBackend(EmailBackend):
|
|||||||
raise SMTPRecipientsRefused(senderrs)
|
raise SMTPRecipientsRefused(senderrs)
|
||||||
finally:
|
finally:
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
class BaseHTMLMailRenderer:
|
||||||
|
"""
|
||||||
|
This is the base class for all HTML e-mail renderers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, event: Event):
|
||||||
|
self.event = event
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.identifier
|
||||||
|
|
||||||
|
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None) -> str:
|
||||||
|
"""
|
||||||
|
This method should generate the HTML part of the email.
|
||||||
|
|
||||||
|
:param plain_body: The body of the email in plain text.
|
||||||
|
:param plain_signature: The signature with event organizer contact details in plain text.
|
||||||
|
:param subject: The email subject.
|
||||||
|
:param order: The order if this email is connected to one, otherwise ``None``.
|
||||||
|
:return: An HTML string
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def verbose_name(self) -> str:
|
||||||
|
"""
|
||||||
|
A human-readable name for this renderer. This should be short but self-explanatory.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identifier(self) -> str:
|
||||||
|
"""
|
||||||
|
A short and unique identifier for this renderer.
|
||||||
|
This should only contain lowercase letters and in most cases will be the same as your package name or prefixed
|
||||||
|
with your package name.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
|
@property
|
||||||
|
def thumbnail_filename(self) -> str:
|
||||||
|
"""
|
||||||
|
A file name discoverable in the static file storage that contains a preview of your renderer. This should
|
||||||
|
be with aspect resolution 4:3.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""
|
||||||
|
This renderer will only be available if this returns ``True``. You can use this to limit this renderer
|
||||||
|
to certain events. Defaults to ``True``.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||||
|
|
||||||
|
@property
|
||||||
|
def template_name(self):
|
||||||
|
raise NotImplemented
|
||||||
|
|
||||||
|
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order) -> str:
|
||||||
|
body_md = bleach.linkify(markdown_compile(plain_body))
|
||||||
|
htmlctx = {
|
||||||
|
'site': settings.PRETIX_INSTANCE_NAME,
|
||||||
|
'site_url': settings.SITE_URL,
|
||||||
|
'body': body_md,
|
||||||
|
'subject': str(subject),
|
||||||
|
'color': '#8E44B3'
|
||||||
|
}
|
||||||
|
if self.event:
|
||||||
|
htmlctx['event'] = self.event
|
||||||
|
htmlctx['color'] = self.event.settings.primary_color
|
||||||
|
|
||||||
|
if plain_signature:
|
||||||
|
signature_md = plain_signature.replace('\n', '<br>\n')
|
||||||
|
signature_md = bleach.linkify(bleach.clean(markdown.markdown(signature_md), tags=bleach.ALLOWED_TAGS + ['p', 'br']))
|
||||||
|
htmlctx['signature'] = signature_md
|
||||||
|
|
||||||
|
if order:
|
||||||
|
htmlctx['order'] = order
|
||||||
|
|
||||||
|
tpl = get_template(self.template_name)
|
||||||
|
body_html = inline_css(tpl.render(htmlctx))
|
||||||
|
return body_html
|
||||||
|
|
||||||
|
|
||||||
|
class ClassicMailRenderer(TemplateBasedMailRenderer):
|
||||||
|
verbose_name = _('pretix default')
|
||||||
|
identifier = 'classic'
|
||||||
|
thumbnail_filename = 'pretixbase/email/thumb.png'
|
||||||
|
template_name = 'pretixbase/email/plainwrapper.html'
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(register_html_mail_renderers, dispatch_uid="pretixbase_email_renderers")
|
||||||
|
def base_renderers(sender, **kwargs):
|
||||||
|
return [ClassicMailRenderer]
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ from zipfile import ZipFile
|
|||||||
|
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from pretix.base.models import OrderPayment
|
||||||
|
|
||||||
from ..exporter import BaseExporter
|
from ..exporter import BaseExporter
|
||||||
from ..services.invoices import invoice_pdf_task
|
from ..services.invoices import invoice_pdf_task
|
||||||
from ..signals import register_data_exporters
|
from ..signals import register_data_exporters
|
||||||
@@ -21,7 +24,14 @@ class InvoiceExporter(BaseExporter):
|
|||||||
qs = self.event.invoices.filter(shredded=False)
|
qs = self.event.invoices.filter(shredded=False)
|
||||||
|
|
||||||
if form_data.get('payment_provider'):
|
if form_data.get('payment_provider'):
|
||||||
qs = qs.filter(order__payment_provider=form_data.get('payment_provider'))
|
qs = qs.annotate(
|
||||||
|
has_payment_with_provider=Exists(
|
||||||
|
OrderPayment.objects.filter(
|
||||||
|
Q(order=OuterRef('pk')) & Q(provider=form_data.get('payment_provider'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
qs = qs.filter(has_payment_with_provider=1)
|
||||||
|
|
||||||
if form_data.get('date_from'):
|
if form_data.get('date_from'):
|
||||||
date_value = form_data.get('date_from')
|
date_value = form_data.get('date_from')
|
||||||
@@ -84,10 +94,10 @@ class InvoiceExporter(BaseExporter):
|
|||||||
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
|
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
|
||||||
],
|
],
|
||||||
required=False,
|
required=False,
|
||||||
help_text=_('Only include invoices for orders that are currently set to this payment provider. '
|
help_text=_('Only include invoices for orders that have at least one payment attempt '
|
||||||
'Note that this might include some invoices of other payment providers or misses '
|
'with this payment provider. '
|
||||||
'some invoices if the payment provider of an order has been changed and a new invoice '
|
'Note that this might include some invoices of orders which in the end have been '
|
||||||
'has been generated.')
|
'fully or partially paid with a different provider.')
|
||||||
)),
|
)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ from decimal import Decimal
|
|||||||
import pytz
|
import pytz
|
||||||
from defusedcsv import csv
|
from defusedcsv import csv
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import Sum
|
from django.db.models import DateTimeField, Max, OuterRef, Subquery, Sum
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.formats import localize
|
from django.utils.formats import localize
|
||||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||||
|
|
||||||
from pretix.base.models import InvoiceAddress, Order, OrderPosition
|
from pretix.base.models import InvoiceAddress, Order, OrderPosition
|
||||||
from pretix.base.models.orders import OrderFee
|
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
|
||||||
|
|
||||||
from ..exporter import BaseExporter
|
from ..exporter import BaseExporter
|
||||||
from ..signals import register_data_exporters
|
from ..signals import register_data_exporters
|
||||||
@@ -55,7 +55,19 @@ class OrderListExporter(BaseExporter):
|
|||||||
tz = pytz.timezone(self.event.settings.timezone)
|
tz = pytz.timezone(self.event.settings.timezone)
|
||||||
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
|
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
|
||||||
|
|
||||||
qs = self.event.orders.all().select_related('invoice_address').prefetch_related('invoices')
|
p_date = OrderPayment.objects.filter(
|
||||||
|
order=OuterRef('pk'),
|
||||||
|
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||||
|
payment_date__isnull=False
|
||||||
|
).values('order').annotate(
|
||||||
|
m=Max('payment_date')
|
||||||
|
).values(
|
||||||
|
'm'
|
||||||
|
).order_by()
|
||||||
|
|
||||||
|
qs = self.event.orders.annotate(
|
||||||
|
payment_date=Subquery(p_date, output_field=DateTimeField())
|
||||||
|
).select_related('invoice_address').prefetch_related('invoices')
|
||||||
if form_data['paid_only']:
|
if form_data['paid_only']:
|
||||||
qs = qs.filter(status=Order.STATUS_PAID)
|
qs = qs.filter(status=Order.STATUS_PAID)
|
||||||
tax_rates = self._get_all_tax_rates(qs)
|
tax_rates = self._get_all_tax_rates(qs)
|
||||||
@@ -63,7 +75,7 @@ class OrderListExporter(BaseExporter):
|
|||||||
headers = [
|
headers = [
|
||||||
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
|
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
|
||||||
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||||
_('Payment date'), _('Payment type'), _('Fees'), _('Order locale')
|
_('Date of last payment'), _('Fees'), _('Order locale')
|
||||||
]
|
]
|
||||||
|
|
||||||
for tr in tax_rates:
|
for tr in tax_rates:
|
||||||
@@ -77,11 +89,6 @@ class OrderListExporter(BaseExporter):
|
|||||||
|
|
||||||
writer.writerow(headers)
|
writer.writerow(headers)
|
||||||
|
|
||||||
provider_names = {
|
|
||||||
k: v.verbose_name
|
|
||||||
for k, v in self.event.get_payment_providers().items()
|
|
||||||
}
|
|
||||||
|
|
||||||
full_fee_sum_cache = {
|
full_fee_sum_cache = {
|
||||||
o['order__id']: o['grosssum'] for o in
|
o['order__id']: o['grosssum'] for o in
|
||||||
OrderFee.objects.values('tax_rate', 'order__id').order_by().annotate(grosssum=Sum('value'))
|
OrderFee.objects.values('tax_rate', 'order__id').order_by().annotate(grosssum=Sum('value'))
|
||||||
@@ -114,7 +121,8 @@ class OrderListExporter(BaseExporter):
|
|||||||
order.invoice_address.street,
|
order.invoice_address.street,
|
||||||
order.invoice_address.zipcode,
|
order.invoice_address.zipcode,
|
||||||
order.invoice_address.city,
|
order.invoice_address.city,
|
||||||
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country_old,
|
order.invoice_address.country if order.invoice_address.country else
|
||||||
|
order.invoice_address.country_old,
|
||||||
order.invoice_address.vat_id,
|
order.invoice_address.vat_id,
|
||||||
]
|
]
|
||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
@@ -122,14 +130,14 @@ class OrderListExporter(BaseExporter):
|
|||||||
|
|
||||||
row += [
|
row += [
|
||||||
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
||||||
provider_names.get(order.payment_provider, order.payment_provider),
|
|
||||||
localize(full_fee_sum_cache.get(order.id) or Decimal('0.00')),
|
localize(full_fee_sum_cache.get(order.id) or Decimal('0.00')),
|
||||||
order.locale,
|
order.locale,
|
||||||
]
|
]
|
||||||
|
|
||||||
for tr in tax_rates:
|
for tr in tax_rates:
|
||||||
taxrate_values = sum_cache.get((order.id, tr), {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')})
|
taxrate_values = sum_cache.get((order.id, tr), {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')})
|
||||||
fee_taxrate_values = fee_sum_cache.get((order.id, tr), {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')})
|
fee_taxrate_values = fee_sum_cache.get((order.id, tr),
|
||||||
|
{'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')})
|
||||||
|
|
||||||
row += [
|
row += [
|
||||||
localize(taxrate_values['grosssum'] + fee_taxrate_values['grosssum']),
|
localize(taxrate_values['grosssum'] + fee_taxrate_values['grosssum']),
|
||||||
@@ -144,6 +152,77 @@ class OrderListExporter(BaseExporter):
|
|||||||
return '{}_orders.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
|
return '{}_orders.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentListExporter(BaseExporter):
|
||||||
|
identifier = 'paymentlistcsv'
|
||||||
|
verbose_name = ugettext_lazy('List of payments and refunds (CSV)')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def export_form_fields(self):
|
||||||
|
return OrderedDict(
|
||||||
|
[
|
||||||
|
('successful_only',
|
||||||
|
forms.BooleanField(
|
||||||
|
label=_('Only successful payments'),
|
||||||
|
initial=True,
|
||||||
|
required=False
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def render(self, form_data: dict):
|
||||||
|
output = io.StringIO()
|
||||||
|
tz = pytz.timezone(self.event.settings.timezone)
|
||||||
|
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
|
||||||
|
|
||||||
|
provider_names = {
|
||||||
|
k: v.verbose_name
|
||||||
|
for k, v in self.event.get_payment_providers().items()
|
||||||
|
}
|
||||||
|
|
||||||
|
payments = OrderPayment.objects.filter(
|
||||||
|
order__event=self.event,
|
||||||
|
).order_by('created')
|
||||||
|
refunds = OrderRefund.objects.filter(
|
||||||
|
order__event=self.event
|
||||||
|
).order_by('created')
|
||||||
|
|
||||||
|
if form_data['successful_only']:
|
||||||
|
payments = payments.filter(
|
||||||
|
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||||
|
)
|
||||||
|
refunds = refunds.filter(
|
||||||
|
state=OrderRefund.REFUND_STATE_DONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
objs = sorted(list(payments) + list(refunds), key=lambda o: o.created)
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||||
|
_('Amount'), _('Payment method')
|
||||||
|
]
|
||||||
|
writer.writerow(headers)
|
||||||
|
|
||||||
|
for obj in objs:
|
||||||
|
if isinstance(obj, OrderPayment) and obj.payment_date:
|
||||||
|
d2 = obj.payment_date.astimezone(tz).date().strftime('%Y-%m-%d')
|
||||||
|
elif isinstance(obj, OrderRefund) and obj.execution_date:
|
||||||
|
d2 = obj.execution_date.astimezone(tz).date().strftime('%Y-%m-%d')
|
||||||
|
else:
|
||||||
|
d2 = ''
|
||||||
|
row = [
|
||||||
|
obj.order.code,
|
||||||
|
obj.full_id,
|
||||||
|
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||||
|
d2,
|
||||||
|
obj.get_state_display(),
|
||||||
|
localize(obj.amount * (-1 if isinstance(obj, OrderRefund) else 1)),
|
||||||
|
provider_names.get(obj.provider, obj.provider)
|
||||||
|
]
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
return '{}_payments.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
class QuotaListExporter(BaseExporter):
|
class QuotaListExporter(BaseExporter):
|
||||||
identifier = 'quotalistcsv'
|
identifier = 'quotalistcsv'
|
||||||
verbose_name = ugettext_lazy('Quota availabilities (CSV)')
|
verbose_name = ugettext_lazy('Quota availabilities (CSV)')
|
||||||
@@ -180,6 +259,11 @@ def register_orderlist_exporter(sender, **kwargs):
|
|||||||
return OrderListExporter
|
return OrderListExporter
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(register_data_exporters, dispatch_uid="exporter_paymentlist")
|
||||||
|
def register_paymentlist_exporter(sender, **kwargs):
|
||||||
|
return PaymentListExporter
|
||||||
|
|
||||||
|
|
||||||
@receiver(register_data_exporters, dispatch_uid="exporter_quotalist")
|
@receiver(register_data_exporters, dispatch_uid="exporter_quotalist")
|
||||||
def register_quotalist_exporter(sender, **kwargs):
|
def register_quotalist_exporter(sender, **kwargs):
|
||||||
return QuotaListExporter
|
return QuotaListExporter
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class LoginForm(forms.Form):
|
|||||||
password = self.cleaned_data.get('password')
|
password = self.cleaned_data.get('password')
|
||||||
|
|
||||||
if email and password:
|
if email and password:
|
||||||
self.user_cache = authenticate(email=email.lower(), password=password)
|
self.user_cache = authenticate(request=self.request, email=email.lower(), password=password)
|
||||||
if self.user_cache is None:
|
if self.user_cache is None:
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
self.error_messages['invalid_login'],
|
self.error_messages['invalid_login'],
|
||||||
@@ -180,12 +180,4 @@ class PasswordForgotForm(forms.Form):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def clean_email(self):
|
def clean_email(self):
|
||||||
email = self.cleaned_data['email']
|
return self.cleaned_data['email']
|
||||||
try:
|
|
||||||
self.cleaned_data['user'] = User.objects.get(email=email)
|
|
||||||
return email
|
|
||||||
except User.DoesNotExist:
|
|
||||||
raise forms.ValidationError(
|
|
||||||
_("We are unable to find a user matching the data you provided."),
|
|
||||||
code='unknown_user'
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -199,6 +199,16 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
|
|
||||||
if event.settings.invoice_name_required:
|
if event.settings.invoice_name_required:
|
||||||
self.fields['name'].required = True
|
self.fields['name'].required = True
|
||||||
|
elif event.settings.invoice_address_company_required:
|
||||||
|
self.initial['is_business'] = True
|
||||||
|
|
||||||
|
self.fields['is_business'].widget = BusinessBooleanRadio(require_business=True)
|
||||||
|
self.fields['company'].required = True
|
||||||
|
self.fields['company'].widget.is_required = True
|
||||||
|
self.fields['company'].widget.attrs['required'] = 'required'
|
||||||
|
del self.fields['company'].widget.attrs['data-display-dependency']
|
||||||
|
if 'vat_id' in self.fields:
|
||||||
|
del self.fields['vat_id'].widget.attrs['data-display-dependency']
|
||||||
else:
|
else:
|
||||||
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
|
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
|
||||||
self.fields['name'].widget.attrs['data-required-if'] = '#id_is_business_0'
|
self.fields['name'].widget.attrs['data-required-if'] = '#id_is_business_0'
|
||||||
|
|||||||
@@ -110,14 +110,22 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
|||||||
|
|
||||||
|
|
||||||
class BusinessBooleanRadio(forms.RadioSelect):
|
class BusinessBooleanRadio(forms.RadioSelect):
|
||||||
def __init__(self, attrs=None):
|
def __init__(self, require_business=False, attrs=None):
|
||||||
choices = (
|
self.require_business = require_business
|
||||||
('individual', _('Individual customer')),
|
if self.require_business:
|
||||||
('business', _('Business customer')),
|
choices = (
|
||||||
)
|
('business', _('Business customer')),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
choices = (
|
||||||
|
('individual', _('Individual customer')),
|
||||||
|
('business', _('Business customer')),
|
||||||
|
)
|
||||||
super().__init__(attrs, choices)
|
super().__init__(attrs, choices)
|
||||||
|
|
||||||
def format_value(self, value):
|
def format_value(self, value):
|
||||||
|
if self.require_business:
|
||||||
|
return 'business'
|
||||||
try:
|
try:
|
||||||
return {True: 'business', False: 'individual'}[value]
|
return {True: 'business', False: 'individual'}[value]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -125,6 +133,8 @@ class BusinessBooleanRadio(forms.RadioSelect):
|
|||||||
|
|
||||||
def value_from_datadict(self, data, files, name):
|
def value_from_datadict(self, data, files, name):
|
||||||
value = data.get(name)
|
value = data.get(name)
|
||||||
|
if self.require_business:
|
||||||
|
return True
|
||||||
return {
|
return {
|
||||||
'business': True,
|
'business': True,
|
||||||
True: True,
|
True: True,
|
||||||
|
|||||||
@@ -184,10 +184,15 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
|||||||
|
|
||||||
class ThumbnailingImageReader(ImageReader):
|
class ThumbnailingImageReader(ImageReader):
|
||||||
def resize(self, width, height, dpi):
|
def resize(self, width, height, dpi):
|
||||||
|
if width is None:
|
||||||
|
width = height * self._image.size[0] / self._image.size[1]
|
||||||
|
if height is None:
|
||||||
|
height = width * self._image.size[1] / self._image.size[0]
|
||||||
self._image.thumbnail(
|
self._image.thumbnail(
|
||||||
size=(int(width * dpi / 72), int(height * dpi / 72)),
|
size=(int(width * dpi / 72), int(height * dpi / 72)),
|
||||||
resample=BICUBIC
|
resample=BICUBIC
|
||||||
)
|
)
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||||
@@ -204,6 +209,18 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
|
|
||||||
canvas.restoreState()
|
canvas.restoreState()
|
||||||
|
|
||||||
|
def _draw_invoice_to(self, canvas):
|
||||||
|
p = Paragraph(self.invoice.invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||||
|
p.wrapOn(canvas, 85 * mm, 50 * mm)
|
||||||
|
p_size = p.wrap(85 * mm, 50 * mm)
|
||||||
|
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
|
||||||
|
|
||||||
|
def _draw_invoice_from(self, canvas):
|
||||||
|
p = Paragraph(self.invoice.invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||||
|
p.wrapOn(canvas, 70 * mm, 50 * mm)
|
||||||
|
p_size = p.wrap(70 * mm, 50 * mm)
|
||||||
|
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
|
||||||
|
|
||||||
def _on_first_page(self, canvas: Canvas, doc):
|
def _on_first_page(self, canvas: Canvas, doc):
|
||||||
canvas.setCreator('pretix.eu')
|
canvas.setCreator('pretix.eu')
|
||||||
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
|
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
|
||||||
@@ -220,20 +237,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
||||||
canvas.drawText(textobject)
|
canvas.drawText(textobject)
|
||||||
|
|
||||||
p = Paragraph(self.invoice.invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
self._draw_invoice_from(canvas)
|
||||||
p.wrapOn(canvas, 70 * mm, 50 * mm)
|
|
||||||
p_size = p.wrap(70 * mm, 50 * mm)
|
|
||||||
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
|
|
||||||
|
|
||||||
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
||||||
textobject.setFont('OpenSansBd', 8)
|
textobject.setFont('OpenSansBd', 8)
|
||||||
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
||||||
canvas.drawText(textobject)
|
canvas.drawText(textobject)
|
||||||
|
|
||||||
p = Paragraph(self.invoice.invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
self._draw_invoice_to(canvas)
|
||||||
p.wrapOn(canvas, 85 * mm, 50 * mm)
|
|
||||||
p_size = p.wrap(85 * mm, 50 * mm)
|
|
||||||
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
|
|
||||||
|
|
||||||
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
||||||
textobject.setFont('OpenSansBd', 8)
|
textobject.setFont('OpenSansBd', 8)
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ class Command(BaseCommand):
|
|||||||
help = "Rebuild static files and language files"
|
help = "Rebuild static files and language files"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
call_command('compilemessages', verbosity=1, interactive=False)
|
call_command('compilemessages', verbosity=1)
|
||||||
call_command('compilejsi18n', verbosity=1, interactive=False)
|
call_command('compilejsi18n', verbosity=1)
|
||||||
call_command('collectstatic', verbosity=1, interactive=False)
|
call_command('collectstatic', verbosity=1, interactive=False)
|
||||||
call_command('compress', verbosity=1, interactive=False)
|
call_command('compress', verbosity=1)
|
||||||
try:
|
try:
|
||||||
gs = GlobalSettingsObject()
|
gs = GlobalSettingsObject()
|
||||||
del gs.settings.update_check_last
|
del gs.settings.update_check_last
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from urllib.parse import urlsplit
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.urlresolvers import get_script_prefix
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.urls import get_script_prefix
|
||||||
from django.utils import timezone, translation
|
from django.utils import timezone, translation
|
||||||
from django.utils.cache import patch_vary_headers
|
from django.utils.cache import patch_vary_headers
|
||||||
from django.utils.deprecation import MiddlewareMixin
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
@@ -172,6 +172,12 @@ class SecurityMiddleware(MiddlewareMixin):
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
resp['X-XSS-Protection'] = '1'
|
resp['X-XSS-Protection'] = '1'
|
||||||
|
|
||||||
|
# We just need to have a P3P, not matter whats in there
|
||||||
|
# https://blogs.msdn.microsoft.com/ieinternals/2013/09/17/a-quick-look-at-p3p/
|
||||||
|
# https://github.com/pretix/pretix/issues/765
|
||||||
|
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
|
||||||
|
|
||||||
h = {
|
h = {
|
||||||
'default-src': ["{static}"],
|
'default-src': ["{static}"],
|
||||||
'script-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
'script-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
# Generated by Django 2.0.8 on 2018-09-11 14:50
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.models import F
|
||||||
|
from django.db.models.functions import Concat
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
|
import pretix.base.models.auth
|
||||||
|
import pretix.base.validators
|
||||||
|
from pretix.base.i18n import language
|
||||||
|
|
||||||
|
|
||||||
|
def create_checkin_lists(apps, schema_editor):
|
||||||
|
Event = apps.get_model('pretixbase', 'Event')
|
||||||
|
Checkin = apps.get_model('pretixbase', 'Checkin')
|
||||||
|
EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
|
||||||
|
for e in Event.objects.all():
|
||||||
|
locale = EventSettingsStore.objects.filter(object=e, key='locale').first()
|
||||||
|
if locale:
|
||||||
|
locale = locale.value
|
||||||
|
else:
|
||||||
|
locale = settings.LANGUAGE_CODE
|
||||||
|
|
||||||
|
if e.has_subevents:
|
||||||
|
for se in e.subevents.all():
|
||||||
|
with language(locale):
|
||||||
|
cl = e.checkin_lists.create(name=se.name, subevent=se, all_products=True)
|
||||||
|
Checkin.objects.filter(position__subevent=se, position__order__event=e).update(list=cl)
|
||||||
|
else:
|
||||||
|
with language(locale):
|
||||||
|
cl = e.checkin_lists.create(name=_('Default list'), all_products=True)
|
||||||
|
Checkin.objects.filter(position__order__event=e).update(list=cl)
|
||||||
|
|
||||||
|
|
||||||
|
def set_full_invoice_no(app, schema_editor):
|
||||||
|
Invoice = app.get_model('pretixbase', 'Invoice')
|
||||||
|
Invoice.objects.all().update(
|
||||||
|
full_invoice_no=Concat(F('prefix'), F('invoice_no'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_position(apps, schema_editor):
|
||||||
|
Question = apps.get_model('pretixbase', 'Question')
|
||||||
|
for q in Question.objects.all():
|
||||||
|
for i, option in enumerate(q.options.all()):
|
||||||
|
option.position = i
|
||||||
|
option.save()
|
||||||
|
|
||||||
|
|
||||||
|
def set_is_staff(apps, schema_editor):
|
||||||
|
User = apps.get_model('pretixbase', 'User')
|
||||||
|
User.objects.filter(is_superuser=True).update(is_staff=True)
|
||||||
|
|
||||||
|
|
||||||
|
def set_identifiers(apps, schema_editor):
|
||||||
|
Question = apps.get_model('pretixbase', 'Question')
|
||||||
|
QuestionOption = apps.get_model('pretixbase', 'QuestionOption')
|
||||||
|
|
||||||
|
for q in Question.objects.select_related('event'):
|
||||||
|
if not q.identifier:
|
||||||
|
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||||
|
while True:
|
||||||
|
code = get_random_string(length=8, allowed_chars=charset)
|
||||||
|
if not Question.objects.filter(event=q.event, identifier=code).exists():
|
||||||
|
q.identifier = code
|
||||||
|
q.save()
|
||||||
|
break
|
||||||
|
|
||||||
|
for q in QuestionOption.objects.select_related('question', 'question__event'):
|
||||||
|
if not q.identifier:
|
||||||
|
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||||
|
while True:
|
||||||
|
code = get_random_string(length=8, allowed_chars=charset)
|
||||||
|
if not QuestionOption.objects.filter(question__event=q.question.event, identifier=code).exists():
|
||||||
|
q.identifier = code
|
||||||
|
q.save()
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
replaces = [('pretixbase', '0077_auto_20171124_1629'), ('pretixbase', '0078_auto_20171206_1603'),
|
||||||
|
('pretixbase', '0079_auto_20180115_0855'), ('pretixbase', '0080_question_ask_during_checkin'),
|
||||||
|
('pretixbase', '0081_auto_20180220_1031'), ('pretixbase', '0082_auto_20180222_0938'),
|
||||||
|
('pretixbase', '0083_auto_20180228_2102'), ('pretixbase', '0084_questionoption_position'),
|
||||||
|
('pretixbase', '0085_auto_20180312_1119'), ('pretixbase', '0086_auto_20180320_1219'),
|
||||||
|
('pretixbase', '0087_auto_20180317_1952'), ('pretixbase', '0088_auto_20180328_1217')]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0076_orderfee_squashed_0082_invoiceaddress_internal_reference'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='eventmetaproperty',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(db_index=True,
|
||||||
|
help_text='Can not contain spaces or special characters except underscores',
|
||||||
|
max_length=50, validators=[django.core.validators.RegexValidator(
|
||||||
|
message='The property name may only contain letters, numbers and underscores.',
|
||||||
|
regex='^[a-zA-Z0-9_]+$')], verbose_name='Name'),
|
||||||
|
),
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CheckinList',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=190)),
|
||||||
|
('all_products',
|
||||||
|
models.BooleanField(default=True, verbose_name='All products (including newly created ones)')),
|
||||||
|
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkin_lists',
|
||||||
|
to='pretixbase.Event')),
|
||||||
|
('subevent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to='pretixbase.SubEvent', verbose_name='Date')),
|
||||||
|
('limit_products',
|
||||||
|
models.ManyToManyField(blank=True, to='pretixbase.Item', verbose_name='Limit to products')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='checkin',
|
||||||
|
name='list',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='checkins', to='pretixbase.CheckinList'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=create_checkin_lists,
|
||||||
|
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='checkin',
|
||||||
|
name='list',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='checkins',
|
||||||
|
to='pretixbase.CheckinList'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='NotificationSetting',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('action_type', models.CharField(max_length=255)),
|
||||||
|
('method', models.CharField(choices=[('mail', 'E-mail')], max_length=255)),
|
||||||
|
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to='pretixbase.Event')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
('enabled', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='notificationsetting',
|
||||||
|
unique_together={('user', 'action_type', 'event', 'method')},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='logentry',
|
||||||
|
name='visible',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='notificationsetting',
|
||||||
|
name='event',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='notification_settings', to='pretixbase.Event'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='notificationsetting',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_settings',
|
||||||
|
to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='notifications_send',
|
||||||
|
field=models.BooleanField(default=True, help_text='If turned off, you will not get any notifications.',
|
||||||
|
verbose_name='Receive notifications according to my settings below'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='notifications_token',
|
||||||
|
field=models.CharField(default=pretix.base.models.auth.generate_notifications_token, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoice',
|
||||||
|
name='full_invoice_no',
|
||||||
|
field=models.CharField(db_index=True, default='', max_length=190),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='question',
|
||||||
|
name='type',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No'),
|
||||||
|
('C', 'Choose one from a list'), ('M', 'Choose multiple from a list'), ('F', 'File upload'),
|
||||||
|
('D', 'Date'), ('H', 'Time'), ('W', 'Date and time')], max_length=5,
|
||||||
|
verbose_name='Question type'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=set_full_invoice_no,
|
||||||
|
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='question',
|
||||||
|
name='ask_during_checkin',
|
||||||
|
field=models.BooleanField(default=False,
|
||||||
|
help_text='This will only work if you handle your check-in with pretixdroid 1.8 '
|
||||||
|
'or '
|
||||||
|
'newer or pretixdesk 0.2 or newer.',
|
||||||
|
verbose_name='Ask during check-in instead of in the ticket buying process'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='checkinlist',
|
||||||
|
name='include_pending',
|
||||||
|
field=models.BooleanField(default=False,
|
||||||
|
help_text='With this option, people will be able to check in even if the order '
|
||||||
|
'have '
|
||||||
|
'not been paid. This only works with pretixdesk 0.3.0 or newer or '
|
||||||
|
'pretixdroid 1.9 or newer.',
|
||||||
|
verbose_name='Include pending orders'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='event',
|
||||||
|
name='presale_end',
|
||||||
|
field=models.DateTimeField(blank=True,
|
||||||
|
help_text='Optional. No products will be sold after this date. If you do not '
|
||||||
|
'set '
|
||||||
|
'this value, the presale will end after the end date of your event.',
|
||||||
|
null=True, verbose_name='End of presale'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='logentry',
|
||||||
|
name='event',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to='pretixbase.Event'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subevent',
|
||||||
|
name='presale_end',
|
||||||
|
field=models.DateTimeField(blank=True,
|
||||||
|
help_text='Optional. No products will be sold after this date. If you do not '
|
||||||
|
'set '
|
||||||
|
'this value, the presale will end after the end date of your event.',
|
||||||
|
null=True, verbose_name='End of presale'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='require_2fa',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Two-factor authentification is required to log in'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='checkin_attention',
|
||||||
|
field=models.BooleanField(default=False,
|
||||||
|
help_text='If you set this, the check-in app will show a visible warning that '
|
||||||
|
'tickets of this order require special attention. This will not show '
|
||||||
|
'any '
|
||||||
|
'details or custom message, so you need to brief your check-in staff '
|
||||||
|
'how '
|
||||||
|
'to handle these cases.',
|
||||||
|
verbose_name='Requires special attention'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='taxrule',
|
||||||
|
name='custom_rules',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderfee',
|
||||||
|
name='fee_type',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'),
|
||||||
|
('other', 'Other fees')], max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='questionoption',
|
||||||
|
options={'ordering': ('position', 'id'), 'verbose_name': 'Question option',
|
||||||
|
'verbose_name_plural': 'Question options'},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='questionoption',
|
||||||
|
name='position',
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='question',
|
||||||
|
name='position',
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name='Position'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=set_position,
|
||||||
|
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='question',
|
||||||
|
name='identifier',
|
||||||
|
field=models.CharField(default='', max_length=190),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='questionoption',
|
||||||
|
name='identifier',
|
||||||
|
field=models.CharField(default='', max_length=190),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='locale',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[('en', 'English'), ('de', 'German'), ('de-informal', 'German (informal)'), ('nl', 'Dutch'),
|
||||||
|
('da', 'Danish'), ('pt-br', 'Portuguese (Brazil)')], default='en', max_length=50,
|
||||||
|
verbose_name='Language'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=set_identifiers,
|
||||||
|
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='cachedcombinedticket',
|
||||||
|
name='file',
|
||||||
|
field=models.FileField(blank=True, max_length=255, null=True,
|
||||||
|
upload_to=pretix.base.models.orders.cachedcombinedticket_name),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='cachedticket',
|
||||||
|
name='file',
|
||||||
|
field=models.FileField(blank=True, max_length=255, null=True,
|
||||||
|
upload_to=pretix.base.models.orders.cachedticket_name),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='invoice',
|
||||||
|
name='file',
|
||||||
|
field=models.FileField(blank=True, max_length=255, null=True,
|
||||||
|
upload_to=pretix.base.models.invoices.invoice_filename),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='question',
|
||||||
|
name='identifier',
|
||||||
|
field=models.CharField(
|
||||||
|
help_text='You can enter any value here to make it easier to match the data with other sources. If '
|
||||||
|
'you do '
|
||||||
|
'not input one, we will generate one automatically.',
|
||||||
|
max_length=190, verbose_name='Internal identifier'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='questionanswer',
|
||||||
|
name='file',
|
||||||
|
field=models.FileField(blank=True, max_length=255, null=True,
|
||||||
|
upload_to=pretix.base.models.orders.answerfile_name),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=set_is_staff,
|
||||||
|
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='user',
|
||||||
|
name='is_superuser',
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StaffSession',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('date_start', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('date_end', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('session_key', models.CharField(max_length=255)),
|
||||||
|
('comment', models.TextField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StaffSessionAuditLog',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('datetime', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('url', models.CharField(max_length=255)),
|
||||||
|
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs',
|
||||||
|
to='pretixbase.StaffSession')),
|
||||||
|
('impersonating', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL)),
|
||||||
|
('method', models.CharField(default='GET', max_length=255)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('datetime',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='staffsession',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='staffsession',
|
||||||
|
options={'ordering': ('date_start',)},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='item',
|
||||||
|
name='picture',
|
||||||
|
field=models.ImageField(blank=True, max_length=255, null=True,
|
||||||
|
upload_to=pretix.base.models.items.itempicture_upload_to,
|
||||||
|
verbose_name='Product picture'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -9,6 +9,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('pretixbase', '0088_auto_20180328_1217'),
|
('pretixbase', '0088_auto_20180328_1217'),
|
||||||
|
('pretixapi', '0001_initial')
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Generated by Django 2.0.8 on 2018-09-11 14:54
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
|
||||||
|
|
||||||
|
def set_pids(apps, schema_editor):
|
||||||
|
OrderPosition = apps.get_model('pretixbase', 'OrderPosition') # noqa
|
||||||
|
taken = set()
|
||||||
|
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||||
|
for op in OrderPosition.objects.iterator():
|
||||||
|
while True:
|
||||||
|
code = get_random_string(length=10, allowed_chars=charset)
|
||||||
|
if code not in taken:
|
||||||
|
op.pseudonymization_id = code
|
||||||
|
taken.add(code)
|
||||||
|
break
|
||||||
|
op.save(update_fields=['pseudonymization_id'])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
replaces = [('pretixbase', '0090_auto_20180509_0917'), ('pretixbase', '0091_auto_20180513_1641'),
|
||||||
|
('pretixbase', '0092_auto_20180511_1224'), ('pretixbase', '0093_auto_20180528_1432'),
|
||||||
|
('pretixbase', '0094_auto_20180604_1119'), ('pretixbase', '0095_auto_20180604_1129')]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0089_auto_20180315_1322'),
|
||||||
|
('pretixapi', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='internal_name',
|
||||||
|
field=models.CharField(blank=True,
|
||||||
|
help_text='If you set this, this will be used instead of the public name in the '
|
||||||
|
'backend.',
|
||||||
|
max_length=255, null=True, verbose_name='Internal name'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='itemcategory',
|
||||||
|
name='internal_name',
|
||||||
|
field=models.CharField(blank=True,
|
||||||
|
help_text='If you set this, this will be used instead of the public name in the '
|
||||||
|
'backend.',
|
||||||
|
max_length=255, null=True, verbose_name='Internal name'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='last_modified',
|
||||||
|
field=models.DateTimeField(auto_now=True, db_index=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='original_price',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=2,
|
||||||
|
help_text='If set, this will be displayed next to the current price to show '
|
||||||
|
'that the current price is a discounted one. This is just a cosmetic '
|
||||||
|
'setting and will not actually impact pricing.',
|
||||||
|
max_digits=7, null=True, verbose_name='Original price'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderposition',
|
||||||
|
name='pseudonymization_id',
|
||||||
|
field=models.CharField(db_index=True, max_length=16, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=set_pids,
|
||||||
|
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderposition',
|
||||||
|
name='pseudonymization_id',
|
||||||
|
field=models.CharField(db_index=True, default='', max_length=16, unique=True),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='logentry',
|
||||||
|
name='oauth_application',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
81
src/pretix/base/migrations/0096_auto_20180722_0801.py
Normal file
81
src/pretix/base/migrations/0096_auto_20180722_0801.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.13 on 2018-07-22 08:01
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0095_auto_20180604_1129'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OrderPayment',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('local_id', models.PositiveIntegerField()),
|
||||||
|
('state', models.CharField(choices=[('created', 'created'), ('pending', 'pending'), ('confirmed', 'confirmed'), ('canceled', 'canceled'), ('failed', 'failed'), ('refunded', 'refunded')], max_length=190)),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Amount')),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('payment_date', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider')),
|
||||||
|
('info', models.TextField(blank=True, null=True, verbose_name='Payment information')),
|
||||||
|
('migrated', models.BooleanField(default=False)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('local_id',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OrderRefund',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('local_id', models.PositiveIntegerField()),
|
||||||
|
('state', models.CharField(choices=[('external', 'started externally'), ('created', 'created'), ('transit', 'in transit'), ('done', 'done'), ('failed', 'failed'), ('canceled', 'canceled')], max_length=190)),
|
||||||
|
('source', models.CharField(choices=[('admin', 'Organizer'), ('buyer', 'Customer'), ('external', 'External')], max_length=190)),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Amount')),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('execution_date', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider')),
|
||||||
|
('info', models.TextField(blank=True, null=True, verbose_name='Payment information')),
|
||||||
|
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='refunds', to='pretixbase.Order', verbose_name='Order')),
|
||||||
|
('payment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='refunds', to='pretixbase.OrderPayment')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('local_id',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='quota',
|
||||||
|
options={'ordering': ('name',), 'verbose_name': 'Quota', 'verbose_name_plural': 'Quotas'},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderfee',
|
||||||
|
name='fee_type',
|
||||||
|
field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'), ('other', 'Other fees'), ('giftcard', 'Gift card')], max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='team',
|
||||||
|
name='can_change_organizer_settings',
|
||||||
|
field=models.BooleanField(default=False, 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!', verbose_name='Can change organizer settings'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='require_2fa',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Two-factor authentication is required to log in'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderpayment',
|
||||||
|
name='fee',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='pretixbase.OrderFee'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderpayment',
|
||||||
|
name='order',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='pretixbase.Order', verbose_name='Order'),
|
||||||
|
),
|
||||||
|
]
|
||||||
118
src/pretix/base/migrations/0097_auto_20180722_0804.py
Normal file
118
src/pretix/base/migrations/0097_auto_20180722_0804.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.13 on 2018-07-22 08:04
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def create_payments(apps, schema_editor):
|
||||||
|
Order = apps.get_model('pretixbase', 'Order') # noqa
|
||||||
|
OrderPayment = apps.get_model('pretixbase', 'OrderPayment') # noqa
|
||||||
|
OrderRefund = apps.get_model('pretixbase', 'OrderRefund') # noqa
|
||||||
|
payments = []
|
||||||
|
refunds = []
|
||||||
|
for o in Order.objects.filter(payments__isnull=True).iterator():
|
||||||
|
if o.status == 'n' or o.status == 'e':
|
||||||
|
payments.append(OrderPayment(
|
||||||
|
local_id=1,
|
||||||
|
state='created',
|
||||||
|
amount=o.total,
|
||||||
|
order=o,
|
||||||
|
provider=o.payment_provider,
|
||||||
|
info=o.payment_info,
|
||||||
|
migrated=True,
|
||||||
|
fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(),
|
||||||
|
))
|
||||||
|
pass
|
||||||
|
elif o.status == 'p':
|
||||||
|
payments.append(OrderPayment(
|
||||||
|
local_id=1,
|
||||||
|
state='confirmed',
|
||||||
|
amount=o.total,
|
||||||
|
order=o,
|
||||||
|
provider=o.payment_provider,
|
||||||
|
payment_date=o.payment_date,
|
||||||
|
info=o.payment_info,
|
||||||
|
migrated=True,
|
||||||
|
fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(),
|
||||||
|
))
|
||||||
|
elif o.status == 'r':
|
||||||
|
p = OrderPayment.objects.create(
|
||||||
|
local_id=1,
|
||||||
|
state='refunded',
|
||||||
|
amount=o.total,
|
||||||
|
order=o,
|
||||||
|
provider=o.payment_provider,
|
||||||
|
payment_date=o.payment_date,
|
||||||
|
info=o.payment_info,
|
||||||
|
migrated=True,
|
||||||
|
fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(),
|
||||||
|
)
|
||||||
|
refunds.append(OrderRefund(
|
||||||
|
local_id=1,
|
||||||
|
state='done',
|
||||||
|
amount=o.total,
|
||||||
|
order=o,
|
||||||
|
provider=o.payment_provider,
|
||||||
|
info=o.payment_info,
|
||||||
|
source='admin',
|
||||||
|
payment=p
|
||||||
|
))
|
||||||
|
elif o.status == 'c':
|
||||||
|
payments.append(OrderPayment(
|
||||||
|
local_id=1,
|
||||||
|
state='canceled',
|
||||||
|
amount=o.total,
|
||||||
|
order=o,
|
||||||
|
provider=o.payment_provider,
|
||||||
|
payment_date=o.payment_date,
|
||||||
|
info=o.payment_info,
|
||||||
|
migrated=True,
|
||||||
|
fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(),
|
||||||
|
))
|
||||||
|
|
||||||
|
if len(payments) > 500:
|
||||||
|
OrderPayment.objects.bulk_create(payments)
|
||||||
|
payments.clear()
|
||||||
|
if len(refunds) > 500:
|
||||||
|
OrderRefund.objects.bulk_create(refunds)
|
||||||
|
refunds.clear()
|
||||||
|
if len(payments) > 0:
|
||||||
|
OrderPayment.objects.bulk_create(payments)
|
||||||
|
if len(refunds) > 0:
|
||||||
|
OrderRefund.objects.bulk_create(refunds)
|
||||||
|
|
||||||
|
|
||||||
|
def notifications(apps, schema_editor):
|
||||||
|
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
|
||||||
|
for n in NotificationSetting.objects.filter(action_type='pretix.event.action_required'):
|
||||||
|
n.pk = None
|
||||||
|
n.action_type = 'pretix.event.order.refund.created.externally'
|
||||||
|
n.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0096_auto_20180722_0801'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_payments, migrations.RunPython.noop),
|
||||||
|
migrations.RunPython(notifications, migrations.RunPython.noop),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='payment_date',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='payment_info',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='payment_manual',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='payment_provider',
|
||||||
|
),
|
||||||
|
]
|
||||||
56
src/pretix/base/migrations/0098_auto_20180731_1243.py
Normal file
56
src/pretix/base/migrations/0098_auto_20180731_1243.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Generated by Django 2.0.7 on 2018-07-31 12:43
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import pretix.base.validators
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0097_auto_20180722_0804'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='logentry',
|
||||||
|
options={'ordering': ('-datetime', '-id')},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderpayment',
|
||||||
|
name='fee',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payments', to='pretixbase.OrderFee'),
|
||||||
|
),
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='staffsession',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='staffsessionauditlog',
|
||||||
|
name='impersonating',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='staffsessionauditlog',
|
||||||
|
name='session',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='logs', to='pretixbase.StaffSession'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='locale',
|
||||||
|
field=models.CharField(choices=[('en', 'English'), ('de', 'German'), ('de-informal', 'German (informal)'), ('nl', 'Dutch'), ('da', 'Danish'), ('tr', 'Turkish'), ('pt-br', 'Portuguese (Brazil)')], default='en', max_length=50, verbose_name='Language'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='event',
|
||||||
|
unique_together={('organizer', 'slug')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Generated by Django 2.0.8 on 2018-09-11 14:54
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import pretix.base.validators
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
replaces = [('pretixbase', '0098_auto_20180731_1243'), ('pretixbase', '0099_auto_20180807_0841'), ('pretixbase', '0100_item_require_approval')]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0097_auto_20180722_0804'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='logentry',
|
||||||
|
options={'ordering': ('-datetime', '-id')},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderpayment',
|
||||||
|
name='fee',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payments', to='pretixbase.OrderFee'),
|
||||||
|
),
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='staffsession',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='staffsessionauditlog',
|
||||||
|
name='impersonating',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='staffsessionauditlog',
|
||||||
|
name='session',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='logs', to='pretixbase.StaffSession'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='locale',
|
||||||
|
field=models.CharField(choices=[('en', 'English'), ('de', 'German'), ('de-informal', 'German (informal)'), ('nl', 'Dutch'), ('da', 'Danish'), ('tr', 'Turkish'), ('pt-br', 'Portuguese (Brazil)')], default='en', max_length=50, verbose_name='Language'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='event',
|
||||||
|
unique_together={('organizer', 'slug')},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='waitinglistentry',
|
||||||
|
options={'ordering': ('-priority', 'created'), 'verbose_name': 'Waiting list entry', 'verbose_name_plural': 'Waiting list entries'},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='waitinglistentry',
|
||||||
|
name='priority',
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='waitinglistentry',
|
||||||
|
name='voucher',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Voucher', verbose_name='Assigned voucher'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='require_approval',
|
||||||
|
field=models.BooleanField(default=False, help_text='If this product is part of an order, the order will be put into an "approval" state and will need to be confirmed by you before it can be paid and completed. You can use this e.g. for discounted tickets that are only available to specific groups.', verbose_name='Buying this product requires approval.'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='require_approval',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
28
src/pretix/base/migrations/0099_auto_20180807_0841.py
Normal file
28
src/pretix/base/migrations/0099_auto_20180807_0841.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 2.1 on 2018-08-07 08:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0098_auto_20180731_1243'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='waitinglistentry',
|
||||||
|
options={'ordering': ('-priority', 'created'), 'verbose_name': 'Waiting list entry', 'verbose_name_plural': 'Waiting list entries'},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='waitinglistentry',
|
||||||
|
name='priority',
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='waitinglistentry',
|
||||||
|
name='voucher',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Voucher', verbose_name='Assigned voucher'),
|
||||||
|
),
|
||||||
|
]
|
||||||
23
src/pretix/base/migrations/0100_item_require_approval.py
Normal file
23
src/pretix/base/migrations/0100_item_require_approval.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 2.1 on 2018-08-09 15:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0099_auto_20180807_0841'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='require_approval',
|
||||||
|
field=models.BooleanField(default=False, help_text='If this product is part of an order, the order will be put into an "approval" state and will need to be confirmed by you before it can be paid and completed. You can use this e.g. for discounted tickets that are only available to specific groups.', verbose_name='Buying this product requires approval.'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='require_approval',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -15,9 +15,9 @@ from .log import LogEntry
|
|||||||
from .notifications import NotificationSetting
|
from .notifications import NotificationSetting
|
||||||
from .orders import (
|
from .orders import (
|
||||||
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
|
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
|
||||||
InvoiceAddress, Order, OrderPosition, QuestionAnswer,
|
InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund,
|
||||||
cachedcombinedticket_name, cachedticket_name, generate_position_secret,
|
QuestionAnswer, cachedcombinedticket_name, cachedticket_name,
|
||||||
generate_secret,
|
generate_position_secret, generate_secret,
|
||||||
)
|
)
|
||||||
from .organizer import (
|
from .organizer import (
|
||||||
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
|
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
|
|
||||||
|
|
||||||
class StaffSession(models.Model):
|
class StaffSession(models.Model):
|
||||||
user = models.ForeignKey('User')
|
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||||
date_start = models.DateTimeField(auto_now_add=True)
|
date_start = models.DateTimeField(auto_now_add=True)
|
||||||
date_end = models.DateTimeField(null=True, blank=True)
|
date_end = models.DateTimeField(null=True, blank=True)
|
||||||
session_key = models.CharField(max_length=255)
|
session_key = models.CharField(max_length=255)
|
||||||
@@ -351,11 +351,11 @@ class StaffSession(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class StaffSessionAuditLog(models.Model):
|
class StaffSessionAuditLog(models.Model):
|
||||||
session = models.ForeignKey('StaffSession', related_name='logs')
|
session = models.ForeignKey('StaffSession', related_name='logs', on_delete=models.PROTECT)
|
||||||
datetime = models.DateTimeField(auto_now_add=True)
|
datetime = models.DateTimeField(auto_now_add=True)
|
||||||
url = models.CharField(max_length=255)
|
url = models.CharField(max_length=255)
|
||||||
method = models.CharField(max_length=255)
|
method = models.CharField(max_length=255)
|
||||||
impersonating = models.ForeignKey('User', null=True, blank=True)
|
impersonating = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('datetime',)
|
ordering = ('datetime',)
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ from pretix.base.models import LoggedModel
|
|||||||
|
|
||||||
|
|
||||||
class CheckinList(LoggedModel):
|
class CheckinList(LoggedModel):
|
||||||
event = models.ForeignKey('Event', related_name='checkin_lists')
|
event = models.ForeignKey('Event', related_name='checkin_lists', on_delete=models.CASCADE)
|
||||||
name = models.CharField(max_length=190)
|
name = models.CharField(max_length=190)
|
||||||
all_products = models.BooleanField(default=True, verbose_name=_("All products (including newly created ones)"))
|
all_products = models.BooleanField(default=True, verbose_name=_("All products (including newly created ones)"))
|
||||||
limit_products = models.ManyToManyField('Item', verbose_name=_("Limit to products"), blank=True)
|
limit_products = models.ManyToManyField('Item', verbose_name=_("Limit to products"), blank=True)
|
||||||
subevent = models.ForeignKey('SubEvent', null=True, blank=True,
|
subevent = models.ForeignKey('SubEvent', null=True, blank=True,
|
||||||
verbose_name=pgettext_lazy('subevent', 'Date'))
|
verbose_name=pgettext_lazy('subevent', 'Date'), on_delete=models.CASCADE)
|
||||||
include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'),
|
include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'),
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_('With this option, people will be able to check in even if the '
|
help_text=_('With this option, people will be able to check in even if the '
|
||||||
@@ -157,7 +157,7 @@ class Checkin(models.Model):
|
|||||||
"""
|
"""
|
||||||
A check-in object is created when a person enters the event.
|
A check-in object is created when a person enters the event.
|
||||||
"""
|
"""
|
||||||
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins')
|
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins', on_delete=models.CASCADE)
|
||||||
datetime = models.DateTimeField(default=now)
|
datetime = models.DateTimeField(default=now)
|
||||||
nonce = models.CharField(max_length=190, null=True, blank=True)
|
nonce = models.CharField(max_length=190, null=True, blank=True)
|
||||||
list = models.ForeignKey(
|
list = models.ForeignKey(
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from django.utils.timezone import make_aware, now
|
|||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from i18nfield.fields import I18nCharField, I18nTextField
|
from i18nfield.fields import I18nCharField, I18nTextField
|
||||||
|
|
||||||
from pretix.base.email import CustomSMTPBackend
|
|
||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
from pretix.base.reldate import RelativeDateWrapper
|
from pretix.base.reldate import RelativeDateWrapper
|
||||||
from pretix.base.validators import EventSlugBlacklistValidator
|
from pretix.base.validators import EventSlugBlacklistValidator
|
||||||
@@ -265,6 +264,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
verbose_name = _("Event")
|
verbose_name = _("Event")
|
||||||
verbose_name_plural = _("Events")
|
verbose_name_plural = _("Events")
|
||||||
ordering = ("date_from", "name")
|
ordering = ("date_from", "name")
|
||||||
|
unique_together = (('organizer', 'slug'),)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.name)
|
return str(self.name)
|
||||||
@@ -326,6 +326,8 @@ class Event(EventMixin, LoggedModel):
|
|||||||
Returns an email server connection, either by using the system-wide connection
|
Returns an email server connection, either by using the system-wide connection
|
||||||
or by returning a custom one based on the event's settings.
|
or by returning a custom one based on the event's settings.
|
||||||
"""
|
"""
|
||||||
|
from pretix.base.email import CustomSMTPBackend
|
||||||
|
|
||||||
if self.settings.smtp_use_custom or force_custom:
|
if self.settings.smtp_use_custom or force_custom:
|
||||||
return CustomSMTPBackend(host=self.settings.smtp_host,
|
return CustomSMTPBackend(host=self.settings.smtp_host,
|
||||||
port=self.settings.smtp_port,
|
port=self.settings.smtp_port,
|
||||||
@@ -450,10 +452,8 @@ class Event(EventMixin, LoggedModel):
|
|||||||
if int(s.value) in tax_map:
|
if int(s.value) in tax_map:
|
||||||
s.value = tax_map.get(int(s.value)).pk
|
s.value = tax_map.get(int(s.value)).pk
|
||||||
s.save()
|
s.save()
|
||||||
else:
|
|
||||||
s.delete()
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
s.delete()
|
pass
|
||||||
else:
|
else:
|
||||||
s.save()
|
s.save()
|
||||||
|
|
||||||
@@ -480,6 +480,31 @@ class Event(EventMixin, LoggedModel):
|
|||||||
|
|
||||||
return OrderedDict(sorted(providers.items(), key=lambda v: str(v[1].verbose_name)))
|
return OrderedDict(sorted(providers.items(), key=lambda v: str(v[1].verbose_name)))
|
||||||
|
|
||||||
|
def get_html_mail_renderer(self):
|
||||||
|
"""
|
||||||
|
Returns the currently selected HTML email renderer
|
||||||
|
"""
|
||||||
|
return self.get_html_mail_renderers()[
|
||||||
|
self.settings.mail_html_renderer
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_html_mail_renderers(self) -> dict:
|
||||||
|
"""
|
||||||
|
Returns a dictionary of initialized HTML email renderers mapped by their identifiers.
|
||||||
|
"""
|
||||||
|
from ..signals import register_html_mail_renderers
|
||||||
|
|
||||||
|
responses = register_html_mail_renderers.send(self)
|
||||||
|
renderers = {}
|
||||||
|
for receiver, response in responses:
|
||||||
|
if not isinstance(response, list):
|
||||||
|
response = [response]
|
||||||
|
for p in response:
|
||||||
|
pp = p(self)
|
||||||
|
if pp.is_available:
|
||||||
|
renderers[pp.identifier] = pp
|
||||||
|
return renderers
|
||||||
|
|
||||||
def get_invoice_renderers(self) -> dict:
|
def get_invoice_renderers(self) -> dict:
|
||||||
"""
|
"""
|
||||||
Returns a dictionary of initialized invoice renderers mapped by their identifiers.
|
Returns a dictionary of initialized invoice renderers mapped by their identifiers.
|
||||||
@@ -551,7 +576,12 @@ class Event(EventMixin, LoggedModel):
|
|||||||
| Q(date_to__gte=now())
|
| Q(date_to__gte=now())
|
||||||
)
|
)
|
||||||
) # order_by doesn't make sense with I18nField
|
) # order_by doesn't make sense with I18nField
|
||||||
return sorted(subevs, key=attrgetter(*orderfields))
|
for f in reversed(orderfields):
|
||||||
|
if f.startswith('-'):
|
||||||
|
subevs = sorted(subevs, key=attrgetter(f[1:]), reverse=True)
|
||||||
|
else:
|
||||||
|
subevs = sorted(subevs, key=attrgetter(f))
|
||||||
|
return subevs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def meta_data(self):
|
def meta_data(self):
|
||||||
@@ -563,7 +593,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
def has_payment_provider(self):
|
def has_payment_provider(self):
|
||||||
result = False
|
result = False
|
||||||
for provider in self.get_payment_providers().values():
|
for provider in self.get_payment_providers().values():
|
||||||
if provider.is_enabled and provider.identifier not in ('free', 'boxoffice'):
|
if provider.is_enabled and provider.identifier not in ('free', 'boxoffice', 'offsetting'):
|
||||||
result = True
|
result = True
|
||||||
break
|
break
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ class Invoice(models.Model):
|
|||||||
:param file: The filename of the rendered invoice
|
:param file: The filename of the rendered invoice
|
||||||
:type file: File
|
:type file: File
|
||||||
"""
|
"""
|
||||||
order = models.ForeignKey('Order', related_name='invoices', db_index=True)
|
order = models.ForeignKey('Order', related_name='invoices', db_index=True, on_delete=models.CASCADE)
|
||||||
organizer = models.ForeignKey('Organizer', related_name='invoices', db_index=True, on_delete=models.PROTECT)
|
organizer = models.ForeignKey('Organizer', related_name='invoices', db_index=True, on_delete=models.PROTECT)
|
||||||
event = models.ForeignKey('Event', related_name='invoices', db_index=True)
|
event = models.ForeignKey('Event', related_name='invoices', db_index=True, on_delete=models.CASCADE)
|
||||||
prefix = models.CharField(max_length=160, db_index=True)
|
prefix = models.CharField(max_length=160, db_index=True)
|
||||||
invoice_no = models.CharField(max_length=19, db_index=True)
|
invoice_no = models.CharField(max_length=19, db_index=True)
|
||||||
full_invoice_no = models.CharField(max_length=190, db_index=True)
|
full_invoice_no = models.CharField(max_length=190, db_index=True)
|
||||||
is_cancellation = models.BooleanField(default=False)
|
is_cancellation = models.BooleanField(default=False)
|
||||||
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True)
|
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True, on_delete=models.CASCADE)
|
||||||
invoice_from = models.TextField()
|
invoice_from = models.TextField()
|
||||||
invoice_to = models.TextField()
|
invoice_to = models.TextField()
|
||||||
date = models.DateField(default=today)
|
date = models.DateField(default=today)
|
||||||
@@ -175,7 +175,7 @@ class InvoiceLine(models.Model):
|
|||||||
:param tax_name: The name of the applied tax rate
|
:param tax_name: The name of the applied tax rate
|
||||||
:type tax_name: str
|
:type tax_name: str
|
||||||
"""
|
"""
|
||||||
invoice = models.ForeignKey('Invoice', related_name='lines')
|
invoice = models.ForeignKey('Invoice', related_name='lines', on_delete=models.CASCADE)
|
||||||
position = models.PositiveIntegerField(default=0)
|
position = models.PositiveIntegerField(default=0)
|
||||||
description = models.TextField()
|
description = models.TextField()
|
||||||
gross_value = models.DecimalField(max_digits=10, decimal_places=2)
|
gross_value = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
|||||||
@@ -193,6 +193,8 @@ class Item(LoggedModel):
|
|||||||
:type checkin_attention: bool
|
:type checkin_attention: bool
|
||||||
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
|
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
|
||||||
:type original_price: decimal.Decimal
|
:type original_price: decimal.Decimal
|
||||||
|
:param require_approval: If set to ``True``, orders containing this product can only be processed and paid after approved by an administrator
|
||||||
|
:type require_approval: bool
|
||||||
"""
|
"""
|
||||||
|
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
@@ -280,6 +282,13 @@ class Item(LoggedModel):
|
|||||||
help_text=_('To buy this product, the user needs a voucher that applies to this product '
|
help_text=_('To buy this product, the user needs a voucher that applies to this product '
|
||||||
'either directly or via a quota.')
|
'either directly or via a quota.')
|
||||||
)
|
)
|
||||||
|
require_approval = models.BooleanField(
|
||||||
|
verbose_name=_('Buying this product requires approval'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('If this product is part of an order, the order will be put into an "approval" state and '
|
||||||
|
'will need to be confirmed by you before it can be paid and completed. You can use this e.g. for '
|
||||||
|
'discounted tickets that are only available to specific groups.'),
|
||||||
|
)
|
||||||
hide_without_voucher = models.BooleanField(
|
hide_without_voucher = models.BooleanField(
|
||||||
verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'),
|
verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'),
|
||||||
default=False,
|
default=False,
|
||||||
@@ -447,7 +456,8 @@ class ItemVariation(models.Model):
|
|||||||
"""
|
"""
|
||||||
item = models.ForeignKey(
|
item = models.ForeignKey(
|
||||||
Item,
|
Item,
|
||||||
related_name='variations'
|
related_name='variations',
|
||||||
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
value = I18nCharField(
|
value = I18nCharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
@@ -562,12 +572,14 @@ class ItemAddOn(models.Model):
|
|||||||
"""
|
"""
|
||||||
base_item = models.ForeignKey(
|
base_item = models.ForeignKey(
|
||||||
Item,
|
Item,
|
||||||
related_name='addons'
|
related_name='addons',
|
||||||
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
addon_category = models.ForeignKey(
|
addon_category = models.ForeignKey(
|
||||||
ItemCategory,
|
ItemCategory,
|
||||||
related_name='addon_to',
|
related_name='addon_to',
|
||||||
verbose_name=_('Category')
|
verbose_name=_('Category'),
|
||||||
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
min_count = models.PositiveIntegerField(
|
min_count = models.PositiveIntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
@@ -679,7 +691,8 @@ class Question(LoggedModel):
|
|||||||
|
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
Event,
|
Event,
|
||||||
related_name="questions"
|
related_name="questions",
|
||||||
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
question = I18nTextField(
|
question = I18nTextField(
|
||||||
verbose_name=_("Question")
|
verbose_name=_("Question")
|
||||||
@@ -831,7 +844,7 @@ class Question(LoggedModel):
|
|||||||
|
|
||||||
|
|
||||||
class QuestionOption(models.Model):
|
class QuestionOption(models.Model):
|
||||||
question = models.ForeignKey('Question', related_name='options')
|
question = models.ForeignKey('Question', related_name='options', on_delete=models.CASCADE)
|
||||||
identifier = models.CharField(max_length=190)
|
identifier = models.CharField(max_length=190)
|
||||||
answer = I18nCharField(verbose_name=_('Answer'))
|
answer = I18nCharField(verbose_name=_('Answer'))
|
||||||
position = models.IntegerField(default=0)
|
position = models.IntegerField(default=0)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class LogEntry(models.Model):
|
|||||||
all = models.Manager()
|
all = models.Manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('-datetime',)
|
ordering = ('-datetime', '-id')
|
||||||
|
|
||||||
def display(self):
|
def display(self):
|
||||||
from ..signals import logentry_display
|
from ..signals import logentry_display
|
||||||
@@ -66,10 +66,13 @@ class LogEntry(models.Model):
|
|||||||
def display_object(self):
|
def display_object(self):
|
||||||
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event, TaxRule, SubEvent
|
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event, TaxRule, SubEvent
|
||||||
|
|
||||||
if self.content_type.model_class() is Event:
|
try:
|
||||||
return ''
|
if self.content_type.model_class() is Event:
|
||||||
|
return ''
|
||||||
|
|
||||||
co = self.content_object
|
co = self.content_object
|
||||||
|
except:
|
||||||
|
return ''
|
||||||
a_map = None
|
a_map = None
|
||||||
a_text = None
|
a_text = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import string
|
import string
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
@@ -9,8 +10,11 @@ from typing import Any, Dict, List, Union
|
|||||||
import dateutil
|
import dateutil
|
||||||
import pytz
|
import pytz
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models, transaction
|
||||||
from django.db.models import F, Sum
|
from django.db.models import (
|
||||||
|
Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
|
||||||
|
)
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
from django.db.models.signals import post_delete
|
from django.db.models.signals import post_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -31,6 +35,8 @@ from .base import LoggedModel
|
|||||||
from .event import Event, SubEvent
|
from .event import Event, SubEvent
|
||||||
from .items import Item, ItemVariation, Question, QuestionOption, Quota
|
from .items import Item, ItemVariation, Question, QuestionOption, Quota
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def generate_secret():
|
def generate_secret():
|
||||||
return get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits)
|
return get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits)
|
||||||
@@ -76,18 +82,14 @@ class Order(LoggedModel):
|
|||||||
:type datetime: datetime
|
:type datetime: datetime
|
||||||
:param expires: The date until this order has to be paid to guarantee the fulfillment
|
:param expires: The date until this order has to be paid to guarantee the fulfillment
|
||||||
:type expires: datetime
|
:type expires: datetime
|
||||||
:param payment_date: The date of the payment completion (null if not yet paid)
|
|
||||||
:type payment_date: datetime
|
|
||||||
:param payment_provider: The payment provider selected by the user
|
|
||||||
:type payment_provider: str
|
|
||||||
:param payment_info: Arbitrary information stored by the payment provider
|
|
||||||
:type payment_info: str
|
|
||||||
:param total: The total amount of the order, including the payment fee
|
:param total: The total amount of the order, including the payment fee
|
||||||
:type total: decimal.Decimal
|
:type total: decimal.Decimal
|
||||||
:param comment: An internal comment that will only be visible to staff, and never displayed to the user
|
:param comment: An internal comment that will only be visible to staff, and never displayed to the user
|
||||||
:type comment: str
|
:type comment: str
|
||||||
:param download_reminder_sent: A field to indicate whether a download reminder has been sent.
|
:param download_reminder_sent: A field to indicate whether a download reminder has been sent.
|
||||||
:type download_reminder_sent: boolean
|
:type download_reminder_sent: boolean
|
||||||
|
:param require_approval: If set to ``True``, this order is pending approval by an organizer
|
||||||
|
:type require_approval: bool
|
||||||
:param meta_info: Additional meta information on the order, JSON-encoded.
|
:param meta_info: Additional meta information on the order, JSON-encoded.
|
||||||
:type meta_info: str
|
:type meta_info: str
|
||||||
"""
|
"""
|
||||||
@@ -119,7 +121,8 @@ class Order(LoggedModel):
|
|||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
Event,
|
Event,
|
||||||
verbose_name=_("Event"),
|
verbose_name=_("Event"),
|
||||||
related_name="orders"
|
related_name="orders",
|
||||||
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
email = models.EmailField(
|
email = models.EmailField(
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
@@ -136,23 +139,6 @@ class Order(LoggedModel):
|
|||||||
expires = models.DateTimeField(
|
expires = models.DateTimeField(
|
||||||
verbose_name=_("Expiration date")
|
verbose_name=_("Expiration date")
|
||||||
)
|
)
|
||||||
payment_date = models.DateTimeField(
|
|
||||||
verbose_name=_("Payment date"),
|
|
||||||
null=True, blank=True
|
|
||||||
)
|
|
||||||
payment_provider = models.CharField(
|
|
||||||
null=True, blank=True,
|
|
||||||
max_length=255,
|
|
||||||
verbose_name=_("Payment provider")
|
|
||||||
)
|
|
||||||
payment_info = models.TextField(
|
|
||||||
verbose_name=_("Payment information"),
|
|
||||||
null=True, blank=True
|
|
||||||
)
|
|
||||||
payment_manual = models.BooleanField(
|
|
||||||
verbose_name=_("Payment state was manually modified"),
|
|
||||||
default=False
|
|
||||||
)
|
|
||||||
total = models.DecimalField(
|
total = models.DecimalField(
|
||||||
decimal_places=2, max_digits=10,
|
decimal_places=2, max_digits=10,
|
||||||
verbose_name=_("Total amount")
|
verbose_name=_("Total amount")
|
||||||
@@ -183,6 +169,9 @@ class Order(LoggedModel):
|
|||||||
last_modified = models.DateTimeField(
|
last_modified = models.DateTimeField(
|
||||||
auto_now=True, db_index=True
|
auto_now=True, db_index=True
|
||||||
)
|
)
|
||||||
|
require_approval = models.BooleanField(
|
||||||
|
default=False
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Order")
|
verbose_name = _("Order")
|
||||||
@@ -199,6 +188,84 @@ class Order(LoggedModel):
|
|||||||
except TypeError:
|
except TypeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def payment_refund_sum(self):
|
||||||
|
payment_sum = self.payments.filter(
|
||||||
|
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
|
||||||
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||||
|
refund_sum = self.refunds.filter(
|
||||||
|
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
|
||||||
|
OrderRefund.REFUND_STATE_CREATED)
|
||||||
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||||
|
return payment_sum - refund_sum
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending_sum(self):
|
||||||
|
total = self.total
|
||||||
|
if self.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
|
||||||
|
total = 0
|
||||||
|
payment_sum = self.payments.filter(
|
||||||
|
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
|
||||||
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||||
|
refund_sum = self.refunds.filter(
|
||||||
|
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
|
||||||
|
OrderRefund.REFUND_STATE_CREATED)
|
||||||
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||||
|
return total - payment_sum + refund_sum
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def annotate_overpayments(cls, qs):
|
||||||
|
payment_sum = OrderPayment.objects.filter(
|
||||||
|
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||||
|
order=OuterRef('pk')
|
||||||
|
).order_by().values('order').annotate(s=Sum('amount')).values('s')
|
||||||
|
refund_sum = OrderRefund.objects.filter(
|
||||||
|
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
|
||||||
|
OrderRefund.REFUND_STATE_CREATED),
|
||||||
|
order=OuterRef('pk')
|
||||||
|
).order_by().values('order').annotate(s=Sum('amount')).values('s')
|
||||||
|
external_refund = OrderRefund.objects.filter(
|
||||||
|
state=OrderRefund.REFUND_STATE_EXTERNAL,
|
||||||
|
order=OuterRef('pk')
|
||||||
|
)
|
||||||
|
pending_refund = OrderRefund.objects.filter(
|
||||||
|
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
|
||||||
|
order=OuterRef('pk')
|
||||||
|
)
|
||||||
|
|
||||||
|
qs = qs.annotate(
|
||||||
|
payment_sum=Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
refund_sum=Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
has_external_refund=Exists(external_refund),
|
||||||
|
has_pending_refund=Exists(pending_refund),
|
||||||
|
).annotate(
|
||||||
|
pending_sum_t=F('total') - Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
|
||||||
|
pending_sum_rc=-1 * Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
|
||||||
|
).annotate(
|
||||||
|
is_overpaid=Case(
|
||||||
|
When(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0),
|
||||||
|
then=Value('1')),
|
||||||
|
When(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0),
|
||||||
|
then=Value('1')),
|
||||||
|
default=Value('0'),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
),
|
||||||
|
is_pending_with_full_payment=Case(
|
||||||
|
When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
|
||||||
|
& Q(require_approval=False),
|
||||||
|
then=Value('1')),
|
||||||
|
default=Value('0'),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
),
|
||||||
|
is_underpaid=Case(
|
||||||
|
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=0),
|
||||||
|
then=Value('1')),
|
||||||
|
default=Value('0'),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return qs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def full_code(self):
|
def full_code(self):
|
||||||
"""
|
"""
|
||||||
@@ -377,7 +444,10 @@ class Order(LoggedModel):
|
|||||||
"payment settings is over."),
|
"payment settings is over."),
|
||||||
'late': _("The payment can not be accepted as it the order is expired and you configured that no late "
|
'late': _("The payment can not be accepted as it the order is expired and you configured that no late "
|
||||||
"payments should be accepted in the payment settings."),
|
"payments should be accepted in the payment settings."),
|
||||||
|
'require_approval': _('This order is not yet approved by the event organizer.')
|
||||||
}
|
}
|
||||||
|
if self.require_approval:
|
||||||
|
return error_messages['require_approval']
|
||||||
term_last = self.payment_term_last
|
term_last = self.payment_term_last
|
||||||
if term_last:
|
if term_last:
|
||||||
if now() > term_last:
|
if now() > term_last:
|
||||||
@@ -425,7 +495,8 @@ class Order(LoggedModel):
|
|||||||
|
|
||||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
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):
|
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||||
|
auth=None):
|
||||||
"""
|
"""
|
||||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||||
|
|
||||||
@@ -450,7 +521,7 @@ class Order(LoggedModel):
|
|||||||
with language(self.locale):
|
with language(self.locale):
|
||||||
recipient = self.email
|
recipient = self.email
|
||||||
try:
|
try:
|
||||||
email_content = render_mail(template, context)[0]
|
email_content = render_mail(template, context)
|
||||||
mail(
|
mail(
|
||||||
recipient, subject, template, context,
|
recipient, subject, template, context,
|
||||||
self.event, self.locale, self, headers, sender,
|
self.event, self.locale, self, headers, sender,
|
||||||
@@ -462,6 +533,7 @@ class Order(LoggedModel):
|
|||||||
self.log_action(
|
self.log_action(
|
||||||
log_entry_type,
|
log_entry_type,
|
||||||
user=user,
|
user=user,
|
||||||
|
auth=auth,
|
||||||
data={
|
data={
|
||||||
'subject': subject,
|
'subject': subject,
|
||||||
'message': email_content,
|
'message': email_content,
|
||||||
@@ -499,14 +571,14 @@ class QuestionAnswer(models.Model):
|
|||||||
"""
|
"""
|
||||||
orderposition = models.ForeignKey(
|
orderposition = models.ForeignKey(
|
||||||
'OrderPosition', null=True, blank=True,
|
'OrderPosition', null=True, blank=True,
|
||||||
related_name='answers'
|
related_name='answers', on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
cartposition = models.ForeignKey(
|
cartposition = models.ForeignKey(
|
||||||
'CartPosition', null=True, blank=True,
|
'CartPosition', null=True, blank=True,
|
||||||
related_name='answers'
|
related_name='answers', on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
question = models.ForeignKey(
|
question = models.ForeignKey(
|
||||||
Question, related_name='answers'
|
Question, related_name='answers', on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
options = models.ManyToManyField(
|
options = models.ManyToManyField(
|
||||||
QuestionOption, related_name='answers', blank=True
|
QuestionOption, related_name='answers', blank=True
|
||||||
@@ -654,7 +726,7 @@ class AbstractPosition(models.Model):
|
|||||||
help_text=_("Empty, if this product is not an admission ticket")
|
help_text=_("Empty, if this product is not an admission ticket")
|
||||||
)
|
)
|
||||||
voucher = models.ForeignKey(
|
voucher = models.ForeignKey(
|
||||||
'Voucher', null=True, blank=True
|
'Voucher', null=True, blank=True, on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
addon_to = models.ForeignKey(
|
addon_to = models.ForeignKey(
|
||||||
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons'
|
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons'
|
||||||
@@ -711,10 +783,441 @@ class AbstractPosition(models.Model):
|
|||||||
else self.variation.quotas.filter(subevent=self.subevent))
|
else self.variation.quotas.filter(subevent=self.subevent))
|
||||||
|
|
||||||
|
|
||||||
|
class OrderPayment(models.Model):
|
||||||
|
"""
|
||||||
|
Represents a payment or payment attempt for an order.
|
||||||
|
|
||||||
|
|
||||||
|
:param id: A globally unique ID for this payment
|
||||||
|
:type id:
|
||||||
|
:param local_id: An ID of this payment, counting from one for every order independently.
|
||||||
|
:type local_id: int
|
||||||
|
:param state: The state of the payment, one of ``created``, ``pending``, ``confirmed``, ``failed``,
|
||||||
|
``canceled``, or ``refunded``.
|
||||||
|
:type state: str
|
||||||
|
:param amount: The payment amount
|
||||||
|
:type amount: Decimal
|
||||||
|
:param order: The order that is paid
|
||||||
|
:type order: Order
|
||||||
|
:param created: The creation time of this record
|
||||||
|
:type created: datetime
|
||||||
|
:param payment_date: The completion time of this payment
|
||||||
|
:type payment_date: datetime
|
||||||
|
:param provider: The payment provider in use
|
||||||
|
:type provider: str
|
||||||
|
:param info: Provider-specific meta information (in JSON format)
|
||||||
|
:type info: str
|
||||||
|
:param fee: The ``OrderFee`` object used to track the fee for this order.
|
||||||
|
:type fee: pretix.base.models.OrderFee
|
||||||
|
"""
|
||||||
|
PAYMENT_STATE_CREATED = 'created'
|
||||||
|
PAYMENT_STATE_PENDING = 'pending'
|
||||||
|
PAYMENT_STATE_CONFIRMED = 'confirmed'
|
||||||
|
PAYMENT_STATE_FAILED = 'failed'
|
||||||
|
PAYMENT_STATE_CANCELED = 'canceled'
|
||||||
|
PAYMENT_STATE_REFUNDED = 'refunded'
|
||||||
|
|
||||||
|
PAYMENT_STATES = (
|
||||||
|
(PAYMENT_STATE_CREATED, pgettext_lazy('payment_state', 'created')),
|
||||||
|
(PAYMENT_STATE_PENDING, pgettext_lazy('payment_state', 'pending')),
|
||||||
|
(PAYMENT_STATE_CONFIRMED, pgettext_lazy('payment_state', 'confirmed')),
|
||||||
|
(PAYMENT_STATE_CANCELED, pgettext_lazy('payment_state', 'canceled')),
|
||||||
|
(PAYMENT_STATE_FAILED, pgettext_lazy('payment_state', 'failed')),
|
||||||
|
(PAYMENT_STATE_REFUNDED, pgettext_lazy('payment_state', 'refunded')),
|
||||||
|
)
|
||||||
|
local_id = models.PositiveIntegerField()
|
||||||
|
state = models.CharField(
|
||||||
|
max_length=190, choices=PAYMENT_STATES
|
||||||
|
)
|
||||||
|
amount = models.DecimalField(
|
||||||
|
decimal_places=2, max_digits=10,
|
||||||
|
verbose_name=_("Amount")
|
||||||
|
)
|
||||||
|
order = models.ForeignKey(
|
||||||
|
Order,
|
||||||
|
verbose_name=_("Order"),
|
||||||
|
related_name='payments',
|
||||||
|
on_delete=models.PROTECT
|
||||||
|
)
|
||||||
|
created = models.DateTimeField(
|
||||||
|
auto_now_add=True
|
||||||
|
)
|
||||||
|
payment_date = models.DateTimeField(
|
||||||
|
null=True, blank=True
|
||||||
|
)
|
||||||
|
provider = models.CharField(
|
||||||
|
null=True, blank=True,
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("Payment provider")
|
||||||
|
)
|
||||||
|
info = models.TextField(
|
||||||
|
verbose_name=_("Payment information"),
|
||||||
|
null=True, blank=True
|
||||||
|
)
|
||||||
|
fee = models.ForeignKey(
|
||||||
|
'OrderFee',
|
||||||
|
null=True, blank=True, related_name='payments', on_delete=models.SET_NULL
|
||||||
|
)
|
||||||
|
migrated = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('local_id',)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def info_data(self):
|
||||||
|
"""
|
||||||
|
This property allows convenient access to the data stored in the ``info``
|
||||||
|
attribute by automatically encoding and decoding the content as JSON.
|
||||||
|
"""
|
||||||
|
return json.loads(self.info) if self.info else {}
|
||||||
|
|
||||||
|
@info_data.setter
|
||||||
|
def info_data(self, d):
|
||||||
|
self.info = json.dumps(d)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def payment_provider(self):
|
||||||
|
"""
|
||||||
|
Cached access to an instance of the payment provider in use.
|
||||||
|
"""
|
||||||
|
return self.order.event.get_payment_providers().get(self.provider)
|
||||||
|
|
||||||
|
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text=''):
|
||||||
|
"""
|
||||||
|
Marks the payment as complete. If possible, this also marks the order as paid if no further
|
||||||
|
payment is required
|
||||||
|
|
||||||
|
:param count_waitinglist: Whether, when calculating quota, people on the waiting list should be taken into
|
||||||
|
consideration (default: ``True``).
|
||||||
|
:type count_waitinglist: boolean
|
||||||
|
:param force: Whether this payment should be marked as paid even if no remaining
|
||||||
|
quota is available (default: ``False``).
|
||||||
|
:type force: boolean
|
||||||
|
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
|
||||||
|
:type send_mail: boolean
|
||||||
|
:param user: The user who performed the change
|
||||||
|
:param auth: The API auth token that performed the change
|
||||||
|
:param mail_text: Additional text to be included in the email
|
||||||
|
:type mail_text: str
|
||||||
|
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
|
||||||
|
"""
|
||||||
|
from pretix.base.signals import order_paid
|
||||||
|
from pretix.base.services.invoices import generate_invoice, invoice_qualified
|
||||||
|
from pretix.base.services.mail import SendMailException
|
||||||
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
|
|
||||||
|
self.state = self.PAYMENT_STATE_CONFIRMED
|
||||||
|
self.payment_date = now()
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
self.order.log_action('pretix.event.order.payment.confirmed', {
|
||||||
|
'local_id': self.local_id,
|
||||||
|
'provider': self.provider,
|
||||||
|
}, user=user, auth=auth)
|
||||||
|
|
||||||
|
if self.order.status == Order.STATUS_PAID:
|
||||||
|
return
|
||||||
|
|
||||||
|
payment_sum = self.order.payments.filter(
|
||||||
|
state__in=(self.PAYMENT_STATE_CONFIRMED, self.PAYMENT_STATE_REFUNDED)
|
||||||
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||||
|
refund_sum = self.order.refunds.filter(
|
||||||
|
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
|
||||||
|
OrderRefund.REFUND_STATE_CREATED)
|
||||||
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||||
|
if payment_sum - refund_sum < self.order.total:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.order.event.lock():
|
||||||
|
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist)
|
||||||
|
if not force and can_be_paid is not True:
|
||||||
|
raise Quota.QuotaExceededException(can_be_paid)
|
||||||
|
self.order.status = Order.STATUS_PAID
|
||||||
|
self.order.save()
|
||||||
|
|
||||||
|
self.order.log_action('pretix.event.order.paid', {
|
||||||
|
'provider': self.provider,
|
||||||
|
'info': self.info,
|
||||||
|
'date': self.payment_date,
|
||||||
|
'force': force
|
||||||
|
}, user=user, auth=auth)
|
||||||
|
order_paid.send(self.order.event, order=self.order)
|
||||||
|
|
||||||
|
invoice = None
|
||||||
|
if invoice_qualified(self.order):
|
||||||
|
invoices = self.order.invoices.filter(is_cancellation=False).count()
|
||||||
|
cancellations = self.order.invoices.filter(is_cancellation=True).count()
|
||||||
|
gen_invoice = (
|
||||||
|
(invoices == 0 and self.order.event.settings.get('invoice_generate') in ('True', 'paid')) or
|
||||||
|
0 < invoices <= cancellations
|
||||||
|
)
|
||||||
|
if gen_invoice:
|
||||||
|
invoice = generate_invoice(
|
||||||
|
self.order,
|
||||||
|
trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment
|
||||||
|
)
|
||||||
|
|
||||||
|
if send_mail:
|
||||||
|
with language(self.order.locale):
|
||||||
|
try:
|
||||||
|
invoice_name = self.order.invoice_address.name
|
||||||
|
invoice_company = self.order.invoice_address.company
|
||||||
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
invoice_name = ""
|
||||||
|
invoice_company = ""
|
||||||
|
email_template = self.order.event.settings.mail_text_order_paid
|
||||||
|
email_context = {
|
||||||
|
'event': self.order.event.name,
|
||||||
|
'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={
|
||||||
|
'order': self.order.code,
|
||||||
|
'secret': self.order.secret
|
||||||
|
}),
|
||||||
|
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
|
||||||
|
'invoice_name': invoice_name,
|
||||||
|
'invoice_company': invoice_company,
|
||||||
|
'payment_info': mail_text
|
||||||
|
}
|
||||||
|
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
|
||||||
|
try:
|
||||||
|
self.order.send_mail(
|
||||||
|
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 []
|
||||||
|
)
|
||||||
|
except SendMailException:
|
||||||
|
logger.exception('Order paid email could not be sent')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def refunded_amount(self):
|
||||||
|
"""
|
||||||
|
The sum of all refund amounts in ``done``, ``transit``, or ``created`` states associated
|
||||||
|
with this payment.
|
||||||
|
"""
|
||||||
|
return self.refunds.filter(
|
||||||
|
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
|
||||||
|
OrderRefund.REFUND_STATE_CREATED)
|
||||||
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_id(self):
|
||||||
|
"""
|
||||||
|
The full human-readable ID of this payment, constructed by the order code and the ``local_id``
|
||||||
|
field with ``-P-`` in between.
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return '{}-P-{}'.format(self.order.code, self.local_id)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.local_id:
|
||||||
|
self.local_id = (self.order.payments.aggregate(m=Max('local_id'))['m'] or 0) + 1
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def create_external_refund(self, amount=None, execution_date=None, info='{}'):
|
||||||
|
"""
|
||||||
|
This should be called to create an OrderRefund object when a refund has triggered
|
||||||
|
by an external source, e.g. when a credit card payment has been refunded by the
|
||||||
|
credit card provider.
|
||||||
|
|
||||||
|
:param amount: Amount to refund. If not given, the full payment amount will be used.
|
||||||
|
:type amount: Decimal
|
||||||
|
:param execution_date: Date of the refund. Defaults to the current time.
|
||||||
|
:type execution_date: datetime
|
||||||
|
:param info: Additional information, defaults to ``"{}"``.
|
||||||
|
:type info: str
|
||||||
|
:return: OrderRefund
|
||||||
|
"""
|
||||||
|
r = self.order.refunds.create(
|
||||||
|
state=OrderRefund.REFUND_STATE_EXTERNAL,
|
||||||
|
source=OrderRefund.REFUND_SOURCE_EXTERNAL,
|
||||||
|
amount=amount if amount is not None else self.amount,
|
||||||
|
order=self.order,
|
||||||
|
payment=self,
|
||||||
|
execution_date=execution_date or now(),
|
||||||
|
provider=self.provider,
|
||||||
|
info=info
|
||||||
|
)
|
||||||
|
self.order.log_action('pretix.event.order.refund.created.externally', {
|
||||||
|
'local_id': r.local_id,
|
||||||
|
'provider': r.provider,
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRefund(models.Model):
|
||||||
|
"""
|
||||||
|
Represents a refund or refund attempt for an order.
|
||||||
|
|
||||||
|
:param id: A globally unique ID for this refund
|
||||||
|
:type id:
|
||||||
|
:param local_id: An ID of this refund, counting from one for every order independently.
|
||||||
|
:type local_id: int
|
||||||
|
:param state: The state of the refund, one of ``created``, ``transit``, ``external``, ``canceled``,
|
||||||
|
``failed``, or ``done``.
|
||||||
|
:type state: str
|
||||||
|
:param source: How this refund was started, one of ``buyer``, ``admin``, or ``external``.
|
||||||
|
:param amount: The refund amount
|
||||||
|
:type amount: Decimal
|
||||||
|
:param order: The order that is refunded
|
||||||
|
:type order: Order
|
||||||
|
:param created: The creation time of this record
|
||||||
|
:type created: datetime
|
||||||
|
:param execution_date: The completion time of this refund
|
||||||
|
:type execution_date: datetime
|
||||||
|
:param provider: The payment provider in use
|
||||||
|
:type provider: str
|
||||||
|
:param info: Provider-specific meta information in JSON format
|
||||||
|
:type info: dict
|
||||||
|
"""
|
||||||
|
# REFUND_STATE_REQUESTED = 'requested'
|
||||||
|
# REFUND_STATE_APPROVED = 'approved'
|
||||||
|
REFUND_STATE_EXTERNAL = 'external'
|
||||||
|
REFUND_STATE_TRANSIT = 'transit'
|
||||||
|
REFUND_STATE_DONE = 'done'
|
||||||
|
# REFUND_STATE_REJECTED = 'rejected'
|
||||||
|
REFUND_STATE_CANCELED = 'canceled'
|
||||||
|
REFUND_STATE_CREATED = 'created'
|
||||||
|
REFUND_STATE_FAILED = 'failed'
|
||||||
|
|
||||||
|
REFUND_STATES = (
|
||||||
|
# (REFUND_STATE_REQUESTED, pgettext_lazy('refund_state', 'requested')),
|
||||||
|
# (REFUND_STATE_APPROVED, pgettext_lazy('refund_state', 'approved')),
|
||||||
|
(REFUND_STATE_EXTERNAL, pgettext_lazy('refund_state', 'started externally')),
|
||||||
|
(REFUND_STATE_CREATED, pgettext_lazy('refund_state', 'created')),
|
||||||
|
(REFUND_STATE_TRANSIT, pgettext_lazy('refund_state', 'in transit')),
|
||||||
|
(REFUND_STATE_DONE, pgettext_lazy('refund_state', 'done')),
|
||||||
|
(REFUND_STATE_FAILED, pgettext_lazy('refund_state', 'failed')),
|
||||||
|
# (REFUND_STATE_REJECTED, pgettext_lazy('refund_state', 'rejected')),
|
||||||
|
(REFUND_STATE_CANCELED, pgettext_lazy('refund_state', 'canceled')),
|
||||||
|
)
|
||||||
|
|
||||||
|
REFUND_SOURCE_BUYER = 'buyer'
|
||||||
|
REFUND_SOURCE_ADMIN = 'admin'
|
||||||
|
REFUND_SOURCE_EXTERNAL = 'external'
|
||||||
|
|
||||||
|
REFUND_SOURCES = (
|
||||||
|
(REFUND_SOURCE_ADMIN, pgettext_lazy('refund_source', 'Organizer')),
|
||||||
|
(REFUND_SOURCE_BUYER, pgettext_lazy('refund_source', 'Customer')),
|
||||||
|
(REFUND_SOURCE_EXTERNAL, pgettext_lazy('refund_source', 'External')),
|
||||||
|
)
|
||||||
|
|
||||||
|
local_id = models.PositiveIntegerField()
|
||||||
|
state = models.CharField(
|
||||||
|
max_length=190, choices=REFUND_STATES
|
||||||
|
)
|
||||||
|
source = models.CharField(
|
||||||
|
max_length=190, choices=REFUND_SOURCES
|
||||||
|
)
|
||||||
|
amount = models.DecimalField(
|
||||||
|
decimal_places=2, max_digits=10,
|
||||||
|
verbose_name=_("Amount")
|
||||||
|
)
|
||||||
|
order = models.ForeignKey(
|
||||||
|
Order,
|
||||||
|
verbose_name=_("Order"),
|
||||||
|
related_name='refunds',
|
||||||
|
on_delete=models.PROTECT
|
||||||
|
)
|
||||||
|
payment = models.ForeignKey(
|
||||||
|
OrderPayment,
|
||||||
|
null=True, blank=True,
|
||||||
|
related_name='refunds',
|
||||||
|
on_delete=models.PROTECT
|
||||||
|
)
|
||||||
|
created = models.DateTimeField(
|
||||||
|
auto_now_add=True
|
||||||
|
)
|
||||||
|
execution_date = models.DateTimeField(
|
||||||
|
null=True, blank=True
|
||||||
|
)
|
||||||
|
provider = models.CharField(
|
||||||
|
null=True, blank=True,
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("Payment provider")
|
||||||
|
)
|
||||||
|
info = models.TextField(
|
||||||
|
verbose_name=_("Payment information"),
|
||||||
|
null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('local_id',)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def info_data(self):
|
||||||
|
"""
|
||||||
|
This property allows convenient access to the data stored in the ``info``
|
||||||
|
attribute by automatically encoding and decoding the content as JSON.
|
||||||
|
"""
|
||||||
|
return json.loads(self.info) if self.info else {}
|
||||||
|
|
||||||
|
@info_data.setter
|
||||||
|
def info_data(self, d):
|
||||||
|
self.info = json.dumps(d)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def payment_provider(self):
|
||||||
|
"""
|
||||||
|
Cached access to an instance of the payment provider in use.
|
||||||
|
"""
|
||||||
|
return self.order.event.get_payment_providers().get(self.provider)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def done(self, user=None, auth=None):
|
||||||
|
"""
|
||||||
|
Marks the refund as complete. This does not modify the state of the order.
|
||||||
|
|
||||||
|
:param user: The user who performed the change
|
||||||
|
:param user: The API auth token that performed the change
|
||||||
|
"""
|
||||||
|
self.state = self.REFUND_STATE_DONE
|
||||||
|
self.execution_date = self.execution_date or now()
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
self.order.log_action('pretix.event.order.refund.done', {
|
||||||
|
'local_id': self.local_id,
|
||||||
|
'provider': self.provider,
|
||||||
|
}, user=user, auth=auth)
|
||||||
|
|
||||||
|
if self.payment and self.payment.refunded_amount >= self.payment.amount:
|
||||||
|
self.payment.state = OrderPayment.PAYMENT_STATE_REFUNDED
|
||||||
|
self.payment.save(update_fields=['state'])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_id(self):
|
||||||
|
"""
|
||||||
|
The full human-readable ID of this refund, constructed by the order code and the ``local_id``
|
||||||
|
field with ``-R-`` in between.
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return '{}-R-{}'.format(self.order.code, self.local_id)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.local_id:
|
||||||
|
self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class OrderFee(models.Model):
|
class OrderFee(models.Model):
|
||||||
"""
|
"""
|
||||||
An OrderFee objet represents a fee that is added to the order total independently of
|
An OrderFee object represents a fee that is added to the order total independently of
|
||||||
the actual positions. This might for example be a payment or a shipping fee.
|
the actual positions. This might for example be a payment or a shipping fee.
|
||||||
|
|
||||||
|
:param value: Gross price of this fee
|
||||||
|
:type value: Decimal
|
||||||
|
:param order: Order this fee is charged with
|
||||||
|
:type order: Order
|
||||||
|
:param fee_type: The type of the fee, currently ``payment``, ``shipping``, ``service``, ``giftcard``, or ``other``.
|
||||||
|
:type fee_type: str
|
||||||
|
:param description: A human-readable description of the fee
|
||||||
|
:type description: str
|
||||||
|
:param internal_type: An internal string to group fees by, e.g. the identifier string of a payment provider
|
||||||
|
:type internal_type: str
|
||||||
|
:param tax_rate: The tax rate applied to this fee
|
||||||
|
:type tax_rate: Decimal
|
||||||
|
:param tax_rule: The tax rule applied to this fee
|
||||||
|
:type tax_rule: TaxRule
|
||||||
|
:param tax_value: The tax amount included in the price
|
||||||
|
:type tax_value: Decimal
|
||||||
"""
|
"""
|
||||||
FEE_TYPE_PAYMENT = "payment"
|
FEE_TYPE_PAYMENT = "payment"
|
||||||
FEE_TYPE_SHIPPING = "shipping"
|
FEE_TYPE_SHIPPING = "shipping"
|
||||||
@@ -813,6 +1316,18 @@ class OrderPosition(AbstractPosition):
|
|||||||
|
|
||||||
:param order: The order this position is a part of
|
:param order: The order this position is a part of
|
||||||
:type order: Order
|
:type order: Order
|
||||||
|
:param positionid: A local ID of this position, counted for each order individually
|
||||||
|
:type positionid: int
|
||||||
|
:param tax_rate: The tax rate applied to this position
|
||||||
|
:type tax_rate: Decimal
|
||||||
|
:param tax_rule: The tax rule applied to this position
|
||||||
|
:type tax_rule: TaxRule
|
||||||
|
:param tax_value: The tax amount included in the price
|
||||||
|
:type tax_value: Decimal
|
||||||
|
:param secret: The secret used for ticket QR codes
|
||||||
|
:type secret: str
|
||||||
|
:param pseudonymization_id: The QR code content for lead scanning
|
||||||
|
:type pseudonymization_id: str
|
||||||
"""
|
"""
|
||||||
positionid = models.PositiveIntegerField(default=1)
|
positionid = models.PositiveIntegerField(default=1)
|
||||||
order = models.ForeignKey(
|
order = models.ForeignKey(
|
||||||
@@ -956,7 +1471,8 @@ class CartPosition(AbstractPosition):
|
|||||||
"""
|
"""
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
Event,
|
Event,
|
||||||
verbose_name=_("Event")
|
verbose_name=_("Event"),
|
||||||
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
cart_id = models.CharField(
|
cart_id = models.CharField(
|
||||||
max_length=255, null=True, blank=True, db_index=True,
|
max_length=255, null=True, blank=True, db_index=True,
|
||||||
@@ -1000,7 +1516,7 @@ class CartPosition(AbstractPosition):
|
|||||||
|
|
||||||
class InvoiceAddress(models.Model):
|
class InvoiceAddress(models.Model):
|
||||||
last_modified = models.DateTimeField(auto_now=True)
|
last_modified = models.DateTimeField(auto_now=True)
|
||||||
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address')
|
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
|
||||||
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
|
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
|
||||||
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
|
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
|
||||||
name = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
name = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class Organizer(LoggedModel):
|
|||||||
OrganizerSlugBlacklistValidator()
|
OrganizerSlugBlacklistValidator()
|
||||||
],
|
],
|
||||||
verbose_name=_("Short form"),
|
verbose_name=_("Short form"),
|
||||||
|
unique=True
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ EU_CURRENCIES = {
|
|||||||
|
|
||||||
|
|
||||||
class TaxRule(LoggedModel):
|
class TaxRule(LoggedModel):
|
||||||
event = models.ForeignKey('Event', related_name='tax_rules')
|
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
|
||||||
name = I18nCharField(
|
name = I18nCharField(
|
||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
help_text=_('Should be short, e.g. "VAT"'),
|
help_text=_('Should be short, e.g. "VAT"'),
|
||||||
|
|||||||
@@ -137,14 +137,14 @@ class Voucher(LoggedModel):
|
|||||||
item = models.ForeignKey(
|
item = models.ForeignKey(
|
||||||
Item, related_name='vouchers',
|
Item, related_name='vouchers',
|
||||||
verbose_name=_("Product"),
|
verbose_name=_("Product"),
|
||||||
null=True, blank=True,
|
null=True, blank=True, on_delete=models.CASCADE,
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"This product is added to the user's cart if the voucher is redeemed."
|
"This product is added to the user's cart if the voucher is redeemed."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
variation = models.ForeignKey(
|
variation = models.ForeignKey(
|
||||||
ItemVariation, related_name='vouchers',
|
ItemVariation, related_name='vouchers',
|
||||||
null=True, blank=True,
|
null=True, blank=True, on_delete=models.CASCADE,
|
||||||
verbose_name=_("Product variation"),
|
verbose_name=_("Product variation"),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"This variation of the product select above is being used."
|
"This variation of the product select above is being used."
|
||||||
@@ -152,7 +152,7 @@ class Voucher(LoggedModel):
|
|||||||
)
|
)
|
||||||
quota = models.ForeignKey(
|
quota = models.ForeignKey(
|
||||||
Quota, related_name='quota',
|
Quota, related_name='quota',
|
||||||
null=True, blank=True,
|
null=True, blank=True, on_delete=models.CASCADE,
|
||||||
verbose_name=_("Quota"),
|
verbose_name=_("Quota"),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"If enabled, the voucher is valid for any product affected by this quota."
|
"If enabled, the voucher is valid for any product affected by this quota."
|
||||||
|
|||||||
@@ -42,10 +42,12 @@ class WaitingListEntry(LoggedModel):
|
|||||||
voucher = models.ForeignKey(
|
voucher = models.ForeignKey(
|
||||||
'Voucher',
|
'Voucher',
|
||||||
verbose_name=_("Assigned voucher"),
|
verbose_name=_("Assigned voucher"),
|
||||||
null=True, blank=True
|
null=True, blank=True,
|
||||||
|
related_name='waitinglistentries',
|
||||||
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
item = models.ForeignKey(
|
item = models.ForeignKey(
|
||||||
Item, related_name='waitinglistentries',
|
Item, related_name='waitinglistentries', on_delete=models.CASCADE,
|
||||||
verbose_name=_("Product"),
|
verbose_name=_("Product"),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"The product the user waits for."
|
"The product the user waits for."
|
||||||
@@ -53,7 +55,7 @@ class WaitingListEntry(LoggedModel):
|
|||||||
)
|
)
|
||||||
variation = models.ForeignKey(
|
variation = models.ForeignKey(
|
||||||
ItemVariation, related_name='waitinglistentries',
|
ItemVariation, related_name='waitinglistentries',
|
||||||
null=True, blank=True,
|
null=True, blank=True, on_delete=models.CASCADE,
|
||||||
verbose_name=_("Product variation"),
|
verbose_name=_("Product variation"),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"The variation of the product selected above."
|
"The variation of the product selected above."
|
||||||
@@ -63,11 +65,12 @@ class WaitingListEntry(LoggedModel):
|
|||||||
max_length=190,
|
max_length=190,
|
||||||
default='en'
|
default='en'
|
||||||
)
|
)
|
||||||
|
priority = models.IntegerField(default=0)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Waiting list entry")
|
verbose_name = _("Waiting list entry")
|
||||||
verbose_name_plural = _("Waiting list entries")
|
verbose_name_plural = _("Waiting list entries")
|
||||||
ordering = ['created']
|
ordering = ('-priority', 'created')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '%s waits for %s' % (str(self.email), str(self.item))
|
return '%s waits for %s' % (str(self.email), str(self.item))
|
||||||
|
|||||||
@@ -229,6 +229,12 @@ def register_default_notification_types(sender, **kwargs):
|
|||||||
_('Order changed'),
|
_('Order changed'),
|
||||||
_('Order {order.code} has been changed.')
|
_('Order {order.code} has been changed.')
|
||||||
),
|
),
|
||||||
|
ParametrizedOrderNotificationType(
|
||||||
|
sender,
|
||||||
|
'pretix.event.order.refund.created.externally',
|
||||||
|
_('External refund of payment'),
|
||||||
|
_('An external refund for {order.code} has occurred.')
|
||||||
|
),
|
||||||
ParametrizedOrderNotificationType(
|
ParametrizedOrderNotificationType(
|
||||||
sender,
|
sender,
|
||||||
'pretix.event.order.refunded',
|
'pretix.event.order.refunded',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
@@ -6,20 +7,25 @@ from typing import Any, Dict, Union
|
|||||||
import pytz
|
import pytz
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.forms import Form
|
from django.forms import Form
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
|
|
||||||
from pretix.base.models import CartPosition, Event, Order, Quota
|
from pretix.base.forms import PlaceholderValidator
|
||||||
|
from pretix.base.models import (
|
||||||
|
CartPosition, Event, Order, OrderPayment, OrderRefund, Quota,
|
||||||
|
)
|
||||||
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
|
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
|
||||||
from pretix.base.settings import SettingsSandbox
|
from pretix.base.settings import SettingsSandbox
|
||||||
from pretix.base.signals import register_payment_providers
|
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.helpers.money import DecimalTextInput
|
||||||
from pretix.presale.views import get_cart_total
|
from pretix.presale.views import get_cart_total
|
||||||
from pretix.presale.views.cart import get_or_create_cart_id
|
from pretix.presale.views.cart import get_or_create_cart_id
|
||||||
@@ -130,6 +136,16 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError() # NOQA
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
|
@property
|
||||||
|
def abort_pending_allowed(self) -> bool:
|
||||||
|
"""
|
||||||
|
Whether or not a user can abort a payment in pending start to switch to another
|
||||||
|
payment method. This returns ``False`` by default which is no guarantee that
|
||||||
|
aborting a pending payment can never happen, it just hides the frontend button
|
||||||
|
to avoid users accidentally committing double payments.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def settings_form_fields(self) -> dict:
|
def settings_form_fields(self) -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -185,6 +201,28 @@ class BasePaymentProvider:
|
|||||||
widget=I18nTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '2'}}
|
widget_kwargs={'attrs': {'rows': '2'}}
|
||||||
)),
|
)),
|
||||||
|
('_total_min',
|
||||||
|
forms.DecimalField(
|
||||||
|
label=_('Minimum order total'),
|
||||||
|
help_text=_('This payment will be available only if the order total is equal to or exceeds the given '
|
||||||
|
'value. The order total for this purpose may be computed without taking the fees imposed '
|
||||||
|
'by this payment method into account.'),
|
||||||
|
localize=True,
|
||||||
|
required=False,
|
||||||
|
decimal_places=places,
|
||||||
|
widget=DecimalTextInput(places=places)
|
||||||
|
)),
|
||||||
|
('_total_max',
|
||||||
|
forms.DecimalField(
|
||||||
|
label=_('Maximum order total'),
|
||||||
|
help_text=_('This payment will be available only if the order total is equal to or below the given '
|
||||||
|
'value. The order total for this purpose may be computed without taking the fees imposed '
|
||||||
|
'by this payment method into account.'),
|
||||||
|
localize=True,
|
||||||
|
required=False,
|
||||||
|
decimal_places=places,
|
||||||
|
widget=DecimalTextInput(places=places)
|
||||||
|
)),
|
||||||
('_fee_abs',
|
('_fee_abs',
|
||||||
forms.DecimalField(
|
forms.DecimalField(
|
||||||
label=_('Additional fee'),
|
label=_('Additional fee'),
|
||||||
@@ -304,20 +342,40 @@ class BasePaymentProvider:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_allowed(self, request: HttpRequest) -> bool:
|
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
|
||||||
"""
|
"""
|
||||||
You can use this method to disable this payment provider for certain groups
|
You can use this method to disable this payment provider for certain groups
|
||||||
of users, products or other criteria. If this method returns ``False``, the
|
of users, products or other criteria. If this method returns ``False``, the
|
||||||
user will not be able to select this payment method. This will only be called
|
user will not be able to select this payment method. This will only be called
|
||||||
during checkout, not on retrying.
|
during checkout, not on retrying.
|
||||||
|
|
||||||
The default implementation checks for the _availability_date setting to be either unset or in the future.
|
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.
|
||||||
return self._is_still_available(cart_id=get_or_create_cart_id(request))
|
|
||||||
|
|
||||||
def payment_form_render(self, request: HttpRequest) -> str:
|
:param total: The total value without the payment method fee, after taxes.
|
||||||
|
|
||||||
|
.. versionchanged:: 1.17.0
|
||||||
|
|
||||||
|
The ``total`` parameter has been added. For backwards compatibility, this method is called again
|
||||||
|
without this parameter if it raises a ``TypeError`` on first try.
|
||||||
"""
|
"""
|
||||||
When the user selects this provider as his preferred payment method,
|
timing = self._is_still_available(cart_id=get_or_create_cart_id(request))
|
||||||
|
pricing = True
|
||||||
|
|
||||||
|
if (self.settings._total_max is not None or self.settings._total_min is not None) and total is None:
|
||||||
|
raise ImproperlyConfigured('This payment provider does not support maximum or minimum amounts.')
|
||||||
|
|
||||||
|
if self.settings._total_max is not None:
|
||||||
|
pricing = pricing and total <= Decimal(self.settings._total_max)
|
||||||
|
|
||||||
|
if self.settings._total_min is not None:
|
||||||
|
pricing = pricing and total >= Decimal(self.settings._total_min)
|
||||||
|
|
||||||
|
return timing and pricing
|
||||||
|
|
||||||
|
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
|
||||||
|
"""
|
||||||
|
When the user selects this provider as their preferred payment method,
|
||||||
they will be shown the HTML you return from this method.
|
they will be shown the HTML you return from this method.
|
||||||
|
|
||||||
The default implementation will call :py:meth:`checkout_form`
|
The default implementation will call :py:meth:`checkout_form`
|
||||||
@@ -332,8 +390,8 @@ class BasePaymentProvider:
|
|||||||
|
|
||||||
def checkout_confirm_render(self, request) -> str:
|
def checkout_confirm_render(self, request) -> str:
|
||||||
"""
|
"""
|
||||||
If the user has successfully filled in his payment data, they will be redirected
|
If the user has successfully filled in their payment data, they will be redirected
|
||||||
to a confirmation page which lists all details of his order for a final review.
|
to a confirmation page which lists all details of their order for a final review.
|
||||||
This method should return the HTML which should be displayed inside the
|
This method should return the HTML which should be displayed inside the
|
||||||
'Payment' box on this page.
|
'Payment' box on this page.
|
||||||
|
|
||||||
@@ -342,11 +400,19 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError() # NOQA
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
|
def payment_pending_render(self, request: HttpRequest, payment: OrderPayment) -> str:
|
||||||
|
"""
|
||||||
|
Render customer-facing instructions on how to proceed with a pending payment
|
||||||
|
|
||||||
|
:return: HTML
|
||||||
|
"""
|
||||||
|
return ""
|
||||||
|
|
||||||
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]:
|
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]:
|
||||||
"""
|
"""
|
||||||
Will be called after the user selects this provider as his payment method.
|
Will be called after the user selects this provider as their payment method.
|
||||||
If you provided a form to the user to enter payment data, this method should
|
If you provided a form to the user to enter payment data, this method should
|
||||||
at least store the user's input into his session.
|
at least store the user's input into their session.
|
||||||
|
|
||||||
This method should return ``False`` if the user's input was invalid, ``True``
|
This method should return ``False`` if the user's input was invalid, ``True``
|
||||||
if the input was valid and the frontend should continue with default behavior
|
if the input was valid and the frontend should continue with default behavior
|
||||||
@@ -361,7 +427,7 @@ class BasePaymentProvider:
|
|||||||
If your payment method requires you to redirect the user to an external provider,
|
If your payment method requires you to redirect the user to an external provider,
|
||||||
this might be the place to do so.
|
this might be the place to do so.
|
||||||
|
|
||||||
.. IMPORTANT:: If this is called, the user has not yet confirmed his or her order.
|
.. IMPORTANT:: If this is called, the user has not yet confirmed their order.
|
||||||
You may NOT do anything which actually moves money.
|
You may NOT do anything which actually moves money.
|
||||||
|
|
||||||
:param cart: This dictionary contains at least the following keys:
|
:param cart: This dictionary contains at least the following keys:
|
||||||
@@ -396,26 +462,29 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError() # NOQA
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
def payment_perform(self, request: HttpRequest, order: Order) -> str:
|
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
|
||||||
"""
|
"""
|
||||||
After the user has confirmed their purchase, this method will be called to complete
|
After the user has confirmed their purchase, this method will be called to complete
|
||||||
the payment process. This is the place to actually move the money if applicable.
|
the payment process. This is the place to actually move the money if applicable.
|
||||||
If you need any special behavior, you can return a string
|
You will be passed an :py:class:`pretix.base.models.OrderPayment` object that contains
|
||||||
|
the amount of money that should be paid.
|
||||||
|
|
||||||
|
If you need any special behavior, you can return a string
|
||||||
containing the URL the user will be redirected to. If you are done with your process
|
containing the URL the user will be redirected to. If you are done with your process
|
||||||
you should return the user to the order's detail page.
|
you should return the user to the order's detail page.
|
||||||
|
|
||||||
If the payment is completed, you should call ``pretix.base.services.orders.mark_order_paid(order, provider, info)``
|
If the payment is completed, you should call ``payment.confirm()``. Please note that ``this`` might
|
||||||
with ``provider`` being your :py:attr:`identifier` and ``info`` being any string
|
raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this order is over and
|
||||||
you might want to store for later usage. Please note that ``mark_order_paid`` might
|
some of the items are sold out. You should use the exception message to display a meaningful error
|
||||||
raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this
|
to the user.
|
||||||
order is over and some of the items are sold out. You should use the exception message
|
|
||||||
to display a meaningful error to the user.
|
|
||||||
|
|
||||||
The default implementation just returns ``None`` and therefore leaves the
|
The default implementation just returns ``None`` and therefore leaves the
|
||||||
order unpaid. The user will be redirected to the order's detail page by default.
|
order unpaid. The user will be redirected to the order's detail page by default.
|
||||||
|
|
||||||
On errors, you should raise a ``PaymentException``.
|
On errors, you should raise a ``PaymentException``.
|
||||||
|
|
||||||
:param order: The order object
|
:param order: The order object
|
||||||
|
:param payment: An ``OrderPayment`` instance
|
||||||
"""
|
"""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -429,19 +498,6 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def order_pending_render(self, request: HttpRequest, order: Order) -> str:
|
|
||||||
"""
|
|
||||||
If the user visits a detail page of an order which has not yet been paid but
|
|
||||||
this payment method was selected during checkout, this method will be called
|
|
||||||
to provide HTML content for the 'payment' box on the page.
|
|
||||||
|
|
||||||
It should contain instructions on how to continue with the payment process,
|
|
||||||
either in form of text or buttons/links/etc.
|
|
||||||
|
|
||||||
:param order: The order object
|
|
||||||
"""
|
|
||||||
raise NotImplementedError() # NOQA
|
|
||||||
|
|
||||||
def order_change_allowed(self, order: Order) -> bool:
|
def order_change_allowed(self, order: Order) -> bool:
|
||||||
"""
|
"""
|
||||||
Will be called to check whether it is allowed to change the payment method of
|
Will be called to check whether it is allowed to change the payment method of
|
||||||
@@ -451,33 +507,16 @@ class BasePaymentProvider:
|
|||||||
|
|
||||||
:param order: The order object
|
:param order: The order object
|
||||||
"""
|
"""
|
||||||
|
ps = order.pending_sum
|
||||||
|
if self.settings._total_max is not None and ps > Decimal(self.settings._total_max):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.settings._total_min is not None and ps < Decimal(self.settings._total_min):
|
||||||
|
return False
|
||||||
|
|
||||||
return self._is_still_available(order=order)
|
return self._is_still_available(order=order)
|
||||||
|
|
||||||
def order_can_retry(self, order: Order) -> bool:
|
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]:
|
||||||
"""
|
|
||||||
Will be called if the user views the detail page of an unpaid order to determine
|
|
||||||
whether the user should be presented with an option to retry the payment. The default
|
|
||||||
implementation always returns False.
|
|
||||||
|
|
||||||
If you want to enable retrials for your payment method, the best is to just return
|
|
||||||
``self._is_still_available()`` from this method to disable it as soon as the method
|
|
||||||
gets disabled or the methods end date is reached.
|
|
||||||
|
|
||||||
The retry workflow is also used if a user switches to this payment method for an existing
|
|
||||||
order!
|
|
||||||
|
|
||||||
:param order: The order object
|
|
||||||
"""
|
|
||||||
return False
|
|
||||||
|
|
||||||
def retry_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]:
|
|
||||||
"""
|
|
||||||
Deprecated, use order_prepare instead
|
|
||||||
"""
|
|
||||||
raise DeprecationWarning('retry_prepare is deprecated, use order_prepare instead')
|
|
||||||
return self.order_prepare(request, order)
|
|
||||||
|
|
||||||
def order_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]:
|
|
||||||
"""
|
"""
|
||||||
Will be called if the user retries to pay an unpaid order (after the user filled in
|
Will be called if the user retries to pay an unpaid order (after the user filled in
|
||||||
e.g. the form returned by :py:meth:`payment_form`) or if the user changes the payment
|
e.g. the form returned by :py:meth:`payment_form`) or if the user changes the payment
|
||||||
@@ -498,22 +537,9 @@ class BasePaymentProvider:
|
|||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def order_paid_render(self, request: HttpRequest, order: Order) -> str:
|
def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:
|
||||||
"""
|
"""
|
||||||
Will be called if the user views the detail page of a paid order which is
|
Will be called if the *event administrator* views the details of a payment.
|
||||||
associated with this payment provider.
|
|
||||||
|
|
||||||
It should return HTML code which should be displayed to the user or None,
|
|
||||||
if there is nothing to say (like the default implementation does).
|
|
||||||
|
|
||||||
:param order: The order object
|
|
||||||
"""
|
|
||||||
return None
|
|
||||||
|
|
||||||
def order_control_render(self, request: HttpRequest, order: Order) -> str:
|
|
||||||
"""
|
|
||||||
Will be called if the *event administrator* views the detail page of an order
|
|
||||||
which is associated with this payment provider.
|
|
||||||
|
|
||||||
It should return HTML code containing information regarding the current payment
|
It should return HTML code containing information regarding the current payment
|
||||||
status and, if applicable, next steps.
|
status and, if applicable, next steps.
|
||||||
@@ -522,62 +548,44 @@ class BasePaymentProvider:
|
|||||||
|
|
||||||
:param order: The order object
|
:param order: The order object
|
||||||
"""
|
"""
|
||||||
return _('Payment provider: %s' % self.verbose_name)
|
return ''
|
||||||
|
|
||||||
def order_control_refund_render(self, order: Order, request: HttpRequest=None) -> str:
|
def payment_refund_supported(self, payment: OrderPayment) -> bool:
|
||||||
"""
|
"""
|
||||||
Will be called if the event administrator clicks an order's 'refund' button.
|
Will be called to check if the provider supports automatic refunding for this
|
||||||
This can be used to display information *before* the order is being refunded.
|
payment.
|
||||||
|
|
||||||
It should return HTML code which should be displayed to the user. It should
|
|
||||||
contain information about to which extend the money will be refunded
|
|
||||||
automatically.
|
|
||||||
|
|
||||||
:param order: The order object
|
|
||||||
:param request: The HTTP request
|
|
||||||
|
|
||||||
.. versionchanged:: 1.6
|
|
||||||
|
|
||||||
The parameter ``request`` has been added.
|
|
||||||
"""
|
"""
|
||||||
return '<div class="alert alert-warning">%s</div>' % _('The money can not be automatically refunded, '
|
return False
|
||||||
'please transfer the money back manually.')
|
|
||||||
|
|
||||||
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
|
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
|
||||||
"""
|
"""
|
||||||
Will be called if the event administrator confirms the refund.
|
Will be called to check if the provider supports automatic partial refunding for this
|
||||||
|
payment.
|
||||||
This should transfer the money back (if possible). You can return the URL the
|
|
||||||
user should be redirected to if you need special behavior or None to continue
|
|
||||||
with default behavior.
|
|
||||||
|
|
||||||
On failure, you should use Django's message framework to display an error message
|
|
||||||
to the user.
|
|
||||||
|
|
||||||
The default implementation sets the Order's state to refunded and shows a success
|
|
||||||
message.
|
|
||||||
|
|
||||||
:param request: The HTTP request
|
|
||||||
:param order: The order object
|
|
||||||
"""
|
"""
|
||||||
from pretix.base.services.orders import mark_order_refunded
|
return False
|
||||||
|
|
||||||
mark_order_refunded(order, user=request.user)
|
def execute_refund(self, refund: OrderRefund):
|
||||||
messages.success(request, _('The order has been marked as refunded. Please transfer the money '
|
"""
|
||||||
'back to the buyer manually.'))
|
Will be called to execute an refund. Note that refunds have an amount property and can be partial.
|
||||||
|
|
||||||
def shred_payment_info(self, order: Order):
|
This should transfer the money back (if possible).
|
||||||
|
On success, you should call ``refund.done()``.
|
||||||
|
On failure, you should raise a PaymentException.
|
||||||
|
"""
|
||||||
|
raise PaymentException(_('Automatic refunds are not supported by this payment provider.'))
|
||||||
|
|
||||||
|
def shred_payment_info(self, obj: Union[OrderPayment, OrderRefund]):
|
||||||
"""
|
"""
|
||||||
When personal data is removed from an event, this method is called to scrub payment-related data
|
When personal data is removed from an event, this method is called to scrub payment-related data
|
||||||
from an order. By default, it removes all info from the ``payment_info`` attribute. You can override
|
from a payment or refund. By default, it removes all info from the ``info`` attribute. You can override
|
||||||
this behavior if you want to retain attributes that are not personal data on their own, i.e. a
|
this behavior if you want to retain attributes that are not personal data on their own, i.e. a
|
||||||
reference to a transaction in an external system. You can also override this to scrub more data, e.g.
|
reference to a transaction in an external system. You can also override this to scrub more data, e.g.
|
||||||
data from external sources that is saved in LogEntry objects or other places.
|
data from external sources that is saved in LogEntry objects or other places.
|
||||||
|
|
||||||
:param order: An order
|
:param order: An order
|
||||||
"""
|
"""
|
||||||
order.payment_info = None
|
obj.info = '{}'
|
||||||
order.save(update_fields=['payment_info'])
|
obj.save(update_fields=['info'])
|
||||||
|
|
||||||
|
|
||||||
class PaymentException(Exception):
|
class PaymentException(Exception):
|
||||||
@@ -585,25 +593,13 @@ class PaymentException(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class FreeOrderProvider(BasePaymentProvider):
|
class FreeOrderProvider(BasePaymentProvider):
|
||||||
|
is_implicit = True
|
||||||
@property
|
is_enabled = True
|
||||||
def is_implicit(self) -> bool:
|
identifier = "free"
|
||||||
return True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_enabled(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def identifier(self) -> str:
|
|
||||||
return "free"
|
|
||||||
|
|
||||||
def checkout_confirm_render(self, request: HttpRequest) -> str:
|
def checkout_confirm_render(self, request: HttpRequest) -> str:
|
||||||
return _("No payment is required as this order only includes products which are free of charge.")
|
return _("No payment is required as this order only includes products which are free of charge.")
|
||||||
|
|
||||||
def order_pending_render(self, request: HttpRequest, order: Order) -> str:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def payment_is_valid_session(self, request: HttpRequest) -> bool:
|
def payment_is_valid_session(self, request: HttpRequest) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -611,10 +607,9 @@ class FreeOrderProvider(BasePaymentProvider):
|
|||||||
def verbose_name(self) -> str:
|
def verbose_name(self) -> str:
|
||||||
return _("Free of charge")
|
return _("Free of charge")
|
||||||
|
|
||||||
def payment_perform(self, request: HttpRequest, order: Order):
|
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
|
||||||
from pretix.base.services.orders import mark_order_paid
|
|
||||||
try:
|
try:
|
||||||
mark_order_paid(order, 'free', send_mail=False)
|
payment.confirm(send_mail=False)
|
||||||
except Quota.QuotaExceededException as e:
|
except Quota.QuotaExceededException as e:
|
||||||
raise PaymentException(str(e))
|
raise PaymentException(str(e))
|
||||||
|
|
||||||
@@ -622,32 +617,7 @@ class FreeOrderProvider(BasePaymentProvider):
|
|||||||
def settings_form_fields(self) -> dict:
|
def settings_form_fields(self) -> dict:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def order_control_refund_render(self, order: Order) -> str:
|
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
|
||||||
return ''
|
|
||||||
|
|
||||||
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
|
|
||||||
"""
|
|
||||||
Will be called if the event administrator confirms the refund.
|
|
||||||
|
|
||||||
This should transfer the money back (if possible). You can return the URL the
|
|
||||||
user should be redirected to if you need special behavior or None to continue
|
|
||||||
with default behavior.
|
|
||||||
|
|
||||||
On failure, you should use Django's message framework to display an error message
|
|
||||||
to the user.
|
|
||||||
|
|
||||||
The default implementation sets the Order's state to refunded and shows a success
|
|
||||||
message.
|
|
||||||
|
|
||||||
:param request: The HTTP request
|
|
||||||
:param order: The order object
|
|
||||||
"""
|
|
||||||
from pretix.base.services.orders import mark_order_refunded
|
|
||||||
|
|
||||||
mark_order_refunded(order, user=request.user)
|
|
||||||
messages.success(request, _('The order has been marked as refunded.'))
|
|
||||||
|
|
||||||
def is_allowed(self, request: HttpRequest) -> bool:
|
|
||||||
from .services.cart import get_fees
|
from .services.cart import get_fees
|
||||||
|
|
||||||
total = get_cart_total(request)
|
total = get_cart_total(request)
|
||||||
@@ -664,10 +634,9 @@ class BoxOfficeProvider(BasePaymentProvider):
|
|||||||
identifier = "boxoffice"
|
identifier = "boxoffice"
|
||||||
verbose_name = _("Box office")
|
verbose_name = _("Box office")
|
||||||
|
|
||||||
def payment_perform(self, request: HttpRequest, order: Order):
|
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
|
||||||
from pretix.base.services.orders import mark_order_paid
|
|
||||||
try:
|
try:
|
||||||
mark_order_paid(order, 'boxoffice', send_mail=False)
|
payment.confirm(send_mail=False)
|
||||||
except Quota.QuotaExceededException as e:
|
except Quota.QuotaExceededException as e:
|
||||||
raise PaymentException(str(e))
|
raise PaymentException(str(e))
|
||||||
|
|
||||||
@@ -675,22 +644,139 @@ class BoxOfficeProvider(BasePaymentProvider):
|
|||||||
def settings_form_fields(self) -> dict:
|
def settings_form_fields(self) -> dict:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def order_control_refund_render(self, order: Order) -> str:
|
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
|
||||||
return ''
|
|
||||||
|
|
||||||
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
|
|
||||||
from pretix.base.services.orders import mark_order_refunded
|
|
||||||
|
|
||||||
mark_order_refunded(order, user=request.user)
|
|
||||||
messages.success(request, _('The order has been marked as refunded.'))
|
|
||||||
|
|
||||||
def is_allowed(self, request: HttpRequest) -> bool:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def order_change_allowed(self, order: Order) -> bool:
|
def order_change_allowed(self, order: Order) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ManualPayment(BasePaymentProvider):
|
||||||
|
identifier = 'manual'
|
||||||
|
verbose_name = _('Manual payment')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_implicit(self):
|
||||||
|
return 'pretix.plugins.manualpayment' not in self.event.plugins
|
||||||
|
|
||||||
|
def is_allowed(self, request: HttpRequest, total: Decimal=None):
|
||||||
|
return 'pretix.plugins.manualpayment' in self.event.plugins and super().is_allowed(request, total)
|
||||||
|
|
||||||
|
def order_change_allowed(self, order: Order):
|
||||||
|
return 'pretix.plugins.manualpayment' in self.event.plugins and super().order_change_allowed(order)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_name(self):
|
||||||
|
return str(self.settings.get('public_name', as_type=LazyI18nString))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def settings_form_fields(self):
|
||||||
|
d = OrderedDict(
|
||||||
|
[
|
||||||
|
('public_name', I18nFormField(
|
||||||
|
label=_('Payment method name'),
|
||||||
|
widget=I18nTextInput,
|
||||||
|
)),
|
||||||
|
('checkout_description', I18nFormField(
|
||||||
|
label=_('Payment process description during checkout'),
|
||||||
|
help_text=_('This text will be shown during checkout when the user selects this payment method. '
|
||||||
|
'It should give a short explanation on this payment method.'),
|
||||||
|
widget=I18nTextarea,
|
||||||
|
)),
|
||||||
|
('email_instructions', I18nFormField(
|
||||||
|
label=_('Payment process description in order confirmation emails'),
|
||||||
|
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
|
||||||
|
'mails. It should instruct the user on how to proceed with the payment. You can use'
|
||||||
|
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
|
||||||
|
widget=I18nTextarea,
|
||||||
|
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
|
||||||
|
)),
|
||||||
|
('pending_description', I18nFormField(
|
||||||
|
label=_('Payment process description for pending orders'),
|
||||||
|
help_text=_('This text will be shown on the order confirmation page for pending orders. '
|
||||||
|
'It should instruct the user on how to proceed with the payment. You can use'
|
||||||
|
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
|
||||||
|
widget=I18nTextarea,
|
||||||
|
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
|
||||||
|
)),
|
||||||
|
] + list(super().settings_form_fields.items())
|
||||||
|
)
|
||||||
|
d.move_to_end('_enabled', last=False)
|
||||||
|
return d
|
||||||
|
|
||||||
|
def payment_form_render(self, request) -> str:
|
||||||
|
return rich_text(
|
||||||
|
str(self.settings.get('checkout_description', as_type=LazyI18nString))
|
||||||
|
)
|
||||||
|
|
||||||
|
def checkout_prepare(self, request, total):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def payment_is_valid_session(self, request):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def checkout_confirm_render(self, request):
|
||||||
|
return self.payment_form_render(request)
|
||||||
|
|
||||||
|
def format_map(self, order):
|
||||||
|
return {
|
||||||
|
'order': order.code,
|
||||||
|
'total': order.total,
|
||||||
|
'currency': self.event.currency,
|
||||||
|
'total_with_currency': money_filter(order.total, self.event.currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
def order_pending_mail_render(self, order) -> str:
|
||||||
|
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order))
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def order_pending_render(self, request, order) -> str:
|
||||||
|
return rich_text(
|
||||||
|
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(order))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OffsettingProvider(BasePaymentProvider):
|
||||||
|
is_enabled = True
|
||||||
|
identifier = "offsetting"
|
||||||
|
verbose_name = _("Offsetting")
|
||||||
|
is_implicit = True
|
||||||
|
|
||||||
|
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
|
||||||
|
try:
|
||||||
|
payment.confirm()
|
||||||
|
except Quota.QuotaExceededException as e:
|
||||||
|
raise PaymentException(str(e))
|
||||||
|
|
||||||
|
def execute_refund(self, refund: OrderRefund):
|
||||||
|
code = refund.info_data['orders'][0]
|
||||||
|
try:
|
||||||
|
order = Order.objects.get(code=code, event__organizer=self.event.organizer)
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
raise PaymentException(_('You entered an order that could not be found.'))
|
||||||
|
p = order.payments.create(
|
||||||
|
state=OrderPayment.PAYMENT_STATE_PENDING,
|
||||||
|
amount=refund.amount,
|
||||||
|
payment_date=now(),
|
||||||
|
provider='offsetting',
|
||||||
|
info=json.dumps({'orders': [refund.order.code]})
|
||||||
|
)
|
||||||
|
p.confirm()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def settings_form_fields(self) -> dict:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def order_change_allowed(self, order: Order) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:
|
||||||
|
return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders']))
|
||||||
|
|
||||||
|
|
||||||
@receiver(register_payment_providers, dispatch_uid="payment_free")
|
@receiver(register_payment_providers, dispatch_uid="payment_free")
|
||||||
def register_payment_provider(sender, **kwargs):
|
def register_payment_provider(sender, **kwargs):
|
||||||
return [FreeOrderProvider, BoxOfficeProvider]
|
return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from reportlab.pdfbase.ttfonts import TTFont
|
|||||||
from reportlab.pdfgen.canvas import Canvas
|
from reportlab.pdfgen.canvas import Canvas
|
||||||
from reportlab.platypus import Paragraph
|
from reportlab.platypus import Paragraph
|
||||||
|
|
||||||
|
from pretix.base.invoice import ThumbnailingImageReader
|
||||||
from pretix.base.models import Order, OrderPosition
|
from pretix.base.models import Order, OrderPosition
|
||||||
from pretix.base.signals import layout_text_variables
|
from pretix.base.signals import layout_text_variables
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
@@ -210,6 +211,22 @@ class Renderer:
|
|||||||
if 'bolditalic' in styles:
|
if 'bolditalic' in styles:
|
||||||
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
|
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
|
||||||
|
|
||||||
|
def _draw_poweredby(self, canvas: Canvas, op: OrderPosition, o: dict):
|
||||||
|
content = o.get('content', 'dark')
|
||||||
|
img = finders.find('pretixpresale/pdf/powered_by_pretix_{}.png'.format(content))
|
||||||
|
|
||||||
|
ir = ThumbnailingImageReader(img)
|
||||||
|
try:
|
||||||
|
width, height = ir.resize(None, float(o['size']) * mm, 300)
|
||||||
|
except:
|
||||||
|
logger.exception("Can not resize image")
|
||||||
|
pass
|
||||||
|
canvas.drawImage(ir,
|
||||||
|
float(o['left']) * mm, float(o['bottom']) * mm,
|
||||||
|
width=width, height=height,
|
||||||
|
preserveAspectRatio=True, anchor='n',
|
||||||
|
mask='auto')
|
||||||
|
|
||||||
def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, o: dict):
|
def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, o: dict):
|
||||||
content = o.get('content', 'secret')
|
content = o.get('content', 'secret')
|
||||||
if content == 'secret':
|
if content == 'secret':
|
||||||
@@ -284,6 +301,8 @@ class Renderer:
|
|||||||
self._draw_barcodearea(canvas, op, o)
|
self._draw_barcodearea(canvas, op, o)
|
||||||
elif o['type'] == "textarea":
|
elif o['type'] == "textarea":
|
||||||
self._draw_textarea(canvas, op, order, o)
|
self._draw_textarea(canvas, op, order, o)
|
||||||
|
elif o['type'] == "poweredby":
|
||||||
|
self._draw_poweredby(canvas, op, o)
|
||||||
canvas.showPage()
|
canvas.showPage()
|
||||||
|
|
||||||
def render_background(self, buffer, title=_('Ticket')):
|
def render_background(self, buffer, title=_('Ticket')):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from enum import Enum
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
class PluginType(Enum):
|
class PluginType(Enum):
|
||||||
@@ -26,5 +27,7 @@ def get_all_plugins() -> List[type]:
|
|||||||
meta = app.PretixPluginMeta
|
meta = app.PretixPluginMeta
|
||||||
meta.module = app.name
|
meta.module = app.name
|
||||||
meta.app = app
|
meta.app = app
|
||||||
|
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
|
||||||
|
continue
|
||||||
plugins.append(meta)
|
plugins.append(meta)
|
||||||
return plugins
|
return plugins
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class RelativeDateWrapper:
|
|||||||
timeparts = parts[2].split(':')
|
timeparts = parts[2].split(':')
|
||||||
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
|
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
|
||||||
data = RelativeDate(
|
data = RelativeDate(
|
||||||
days_before=int(parts[1]),
|
days_before=int(parts[1] or 0),
|
||||||
base_date_name=parts[3],
|
base_date_name=parts[3],
|
||||||
time=time
|
time=time
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ from pretix.base.models import (
|
|||||||
from pretix.base.models.event import SubEvent
|
from pretix.base.models.event import SubEvent
|
||||||
from pretix.base.models.orders import OrderFee
|
from pretix.base.models.orders import OrderFee
|
||||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||||
from pretix.base.services.async import ProfiledTask
|
|
||||||
from pretix.base.services.locking import LockTimeoutException
|
from pretix.base.services.locking import LockTimeoutException
|
||||||
from pretix.base.services.pricing import get_price
|
from pretix.base.services.pricing import get_price
|
||||||
|
from pretix.base.services.tasks import ProfiledTask
|
||||||
from pretix.base.templatetags.rich_text import rich_text
|
from pretix.base.templatetags.rich_text import rich_text
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.presale.signals import (
|
from pretix.presale.signals import (
|
||||||
@@ -64,6 +64,10 @@ error_messages = {
|
|||||||
'price_too_high': _('The entered price is to high.'),
|
'price_too_high': _('The entered price is to high.'),
|
||||||
'voucher_invalid': _('This voucher code is not known in our database.'),
|
'voucher_invalid': _('This voucher code is not known in our database.'),
|
||||||
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
|
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
|
||||||
|
'voucher_redeemed_cart': _('This voucher code is currently locked since it is already contained in a cart. This '
|
||||||
|
'might mean that someone else is redeeming this voucher right now, or that you tried '
|
||||||
|
'to redeem it before but did not complete the checkout process. You can try to use it '
|
||||||
|
'again in %d minutes.'),
|
||||||
'voucher_redeemed_partial': _('This voucher code can only be redeemed %d more times.'),
|
'voucher_redeemed_partial': _('This voucher code can only be redeemed %d more times.'),
|
||||||
'voucher_double': _('You already used this voucher code. Remove the associated line from your '
|
'voucher_double': _('You already used this voucher code. Remove the associated line from your '
|
||||||
'cart if you want to use it for a different product.'),
|
'cart if you want to use it for a different product.'),
|
||||||
@@ -232,11 +236,17 @@ class CartManager:
|
|||||||
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
||||||
voucher: Optional[Voucher], custom_price: Optional[Decimal],
|
voucher: Optional[Voucher], custom_price: Optional[Decimal],
|
||||||
subevent: Optional[SubEvent], cp_is_net: bool=None):
|
subevent: Optional[SubEvent], cp_is_net: bool=None):
|
||||||
return get_price(
|
try:
|
||||||
item, variation, voucher, custom_price, subevent,
|
return get_price(
|
||||||
custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices,
|
item, variation, voucher, custom_price, subevent,
|
||||||
invoice_address=self.invoice_address
|
custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices,
|
||||||
)
|
invoice_address=self.invoice_address
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
if str(e) == 'price_too_high':
|
||||||
|
raise CartError(error_messages['price_too_high'])
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
def extend_expired_positions(self):
|
def extend_expired_positions(self):
|
||||||
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
|
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
|
||||||
@@ -491,6 +501,7 @@ class CartManager:
|
|||||||
|
|
||||||
def _get_voucher_availability(self):
|
def _get_voucher_availability(self):
|
||||||
vouchers_ok = {}
|
vouchers_ok = {}
|
||||||
|
self._voucher_depend_on_cart = set()
|
||||||
for voucher, count in self._voucher_use_diff.items():
|
for voucher, count in self._voucher_use_diff.items():
|
||||||
voucher.refresh_from_db()
|
voucher.refresh_from_db()
|
||||||
|
|
||||||
@@ -503,7 +514,10 @@ class CartManager:
|
|||||||
).exclude(pk__in=[
|
).exclude(pk__in=[
|
||||||
op.position.voucher_id for op in self._operations if isinstance(op, self.ExtendOperation)
|
op.position.voucher_id for op in self._operations if isinstance(op, self.ExtendOperation)
|
||||||
])
|
])
|
||||||
v_avail = voucher.max_usages - voucher.redeemed - redeemed_in_carts.count()
|
cart_count = redeemed_in_carts.count()
|
||||||
|
v_avail = voucher.max_usages - voucher.redeemed - cart_count
|
||||||
|
if cart_count > 0:
|
||||||
|
self._voucher_depend_on_cart.add(voucher)
|
||||||
vouchers_ok[voucher] = v_avail
|
vouchers_ok[voucher] = v_avail
|
||||||
|
|
||||||
return vouchers_ok
|
return vouchers_ok
|
||||||
@@ -574,7 +588,10 @@ class CartManager:
|
|||||||
err = err or error_messages['in_part']
|
err = err or error_messages['in_part']
|
||||||
|
|
||||||
if voucher_available_count < 1:
|
if voucher_available_count < 1:
|
||||||
err = err or error_messages['voucher_redeemed']
|
if op.voucher in self._voucher_depend_on_cart:
|
||||||
|
err = err or error_messages['voucher_redeemed_cart'] % self.event.settings.reservation_time
|
||||||
|
else:
|
||||||
|
err = err or error_messages['voucher_redeemed']
|
||||||
elif voucher_available_count < requested_count:
|
elif voucher_available_count < requested_count:
|
||||||
err = err or error_messages['voucher_redeemed_partial'] % voucher_available_count
|
err = err or error_messages['voucher_redeemed_partial'] % voucher_available_count
|
||||||
|
|
||||||
|
|||||||
@@ -116,10 +116,13 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
|||||||
require_answers
|
require_answers
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ci, created = Checkin.objects.get_or_create(position=op, list=clist, defaults={
|
try:
|
||||||
'datetime': dt,
|
ci, created = Checkin.objects.get_or_create(position=op, list=clist, defaults={
|
||||||
'nonce': nonce,
|
'datetime': dt,
|
||||||
})
|
'nonce': nonce,
|
||||||
|
})
|
||||||
|
except Checkin.MultipleObjectsReturned:
|
||||||
|
ci, created = Checkin.objects.filter(position=op, list=clist).last(), False
|
||||||
|
|
||||||
if created or (nonce and nonce == ci.nonce):
|
if created or (nonce and nonce == ci.nonce):
|
||||||
if created:
|
if created:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from django.utils.timezone import override
|
|||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import CachedFile, Event, cachedfile_name
|
from pretix.base.models import CachedFile, Event, cachedfile_name
|
||||||
from pretix.base.services.async import ProfiledTask
|
from pretix.base.services.tasks import ProfiledTask
|
||||||
from pretix.base.signals import register_data_exporters
|
from pretix.base.signals import register_data_exporters
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -18,29 +17,35 @@ from django.utils.translation import pgettext, ugettext as _
|
|||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order
|
from pretix.base.models import (
|
||||||
|
Invoice, InvoiceAddress, InvoiceLine, Order, OrderPayment,
|
||||||
|
)
|
||||||
from pretix.base.models.tax import EU_CURRENCIES
|
from pretix.base.models.tax import EU_CURRENCIES
|
||||||
from pretix.base.services.async import TransactionAwareTask
|
from pretix.base.services.tasks import TransactionAwareTask
|
||||||
from pretix.base.settings import GlobalSettingsObject
|
from pretix.base.settings import GlobalSettingsObject
|
||||||
from pretix.base.signals import periodic_task
|
from pretix.base.signals import periodic_task
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.helpers.database import rolledback_transaction
|
from pretix.helpers.database import rolledback_transaction
|
||||||
|
from pretix.helpers.models import modelcopy
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def build_invoice(invoice: Invoice) -> Invoice:
|
def build_invoice(invoice: Invoice) -> Invoice:
|
||||||
with language(invoice.locale):
|
lp = invoice.order.payments.last()
|
||||||
payment_provider = invoice.event.get_payment_providers().get(invoice.order.payment_provider)
|
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')
|
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
|
||||||
|
|
||||||
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
|
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
|
||||||
additional = invoice.event.settings.get('invoice_additional_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)
|
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
|
||||||
if payment_provider:
|
if open_payment and open_payment.payment_provider:
|
||||||
payment = payment_provider.render_invoice_text(invoice.order)
|
payment = open_payment.payment_provider.render_invoice_text(invoice.order)
|
||||||
else:
|
else:
|
||||||
payment = ""
|
payment = ""
|
||||||
|
|
||||||
@@ -166,7 +171,7 @@ def build_cancellation(invoice: Invoice):
|
|||||||
|
|
||||||
|
|
||||||
def generate_cancellation(invoice: Invoice, trigger_pdf=True):
|
def generate_cancellation(invoice: Invoice, trigger_pdf=True):
|
||||||
cancellation = copy.copy(invoice)
|
cancellation = modelcopy(invoice)
|
||||||
cancellation.pk = None
|
cancellation.pk = None
|
||||||
cancellation.invoice_no = None
|
cancellation.invoice_no = None
|
||||||
cancellation.prefix = None
|
cancellation.prefix = None
|
||||||
@@ -232,7 +237,7 @@ def invoice_pdf_task(invoice: int):
|
|||||||
|
|
||||||
|
|
||||||
def invoice_qualified(order: Order):
|
def invoice_qualified(order: Order):
|
||||||
if order.total == Decimal('0.00'):
|
if order.total == Decimal('0.00') or order.require_approval:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,19 @@ import logging
|
|||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
from typing import Any, Dict, List, Union
|
from typing import Any, Dict, List, Union
|
||||||
|
|
||||||
import bleach
|
|
||||||
import cssutils
|
import cssutils
|
||||||
import markdown
|
|
||||||
from celery import chain
|
from celery import chain
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
from inlinestyler.utils import inline_css
|
|
||||||
|
|
||||||
|
from pretix.base.email import ClassicMailRenderer
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import Event, Invoice, InvoiceAddress, Order
|
from pretix.base.models import Event, Invoice, InvoiceAddress, Order
|
||||||
from pretix.base.services.invoices import invoice_pdf_task
|
from pretix.base.services.invoices import invoice_pdf_task
|
||||||
from pretix.base.signals import email_filter
|
from pretix.base.signals import email_filter
|
||||||
from pretix.base.templatetags.rich_text import markdown_compile
|
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
|
|
||||||
@@ -88,7 +85,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
|||||||
'invoice_name': '',
|
'invoice_name': '',
|
||||||
'invoice_company': ''
|
'invoice_company': ''
|
||||||
})
|
})
|
||||||
body, body_md = render_mail(template, context)
|
renderer = ClassicMailRenderer(None)
|
||||||
|
content_plain = body_plain = render_mail(template, context)
|
||||||
subject = str(subject).format_map(context)
|
subject = str(subject).format_map(context)
|
||||||
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
|
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
|
||||||
if event:
|
if event:
|
||||||
@@ -97,23 +95,20 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
|||||||
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
|
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
|
||||||
|
|
||||||
subject = str(subject)
|
subject = str(subject)
|
||||||
body_plain = body
|
signature = ""
|
||||||
|
|
||||||
htmlctx = {
|
|
||||||
'site': settings.PRETIX_INSTANCE_NAME,
|
|
||||||
'site_url': settings.SITE_URL,
|
|
||||||
'body': body_md,
|
|
||||||
'color': '#8E44B3'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
bcc = []
|
||||||
if event:
|
if event:
|
||||||
htmlctx['event'] = event
|
renderer = event.get_html_mail_renderer()
|
||||||
htmlctx['color'] = event.settings.primary_color
|
if event.settings.mail_bcc:
|
||||||
|
bcc.append(event.settings.mail_bcc)
|
||||||
|
|
||||||
if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail and not headers.get('Reply-To'):
|
if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail and not headers.get('Reply-To'):
|
||||||
headers['Reply-To'] = event.settings.contact_mail
|
headers['Reply-To'] = event.settings.contact_mail
|
||||||
|
|
||||||
prefix = event.settings.get('mail_prefix')
|
prefix = event.settings.get('mail_prefix')
|
||||||
|
if prefix and prefix.startswith('[') and prefix.endswith(']'):
|
||||||
|
prefix = prefix[1:-1]
|
||||||
if prefix:
|
if prefix:
|
||||||
subject = "[%s] %s" % (prefix, subject)
|
subject = "[%s] %s" % (prefix, subject)
|
||||||
|
|
||||||
@@ -122,9 +117,6 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
|||||||
signature = str(event.settings.get('mail_text_signature'))
|
signature = str(event.settings.get('mail_text_signature'))
|
||||||
if signature:
|
if signature:
|
||||||
signature = signature.format(event=event.name)
|
signature = signature.format(event=event.name)
|
||||||
signature_md = signature.replace('\n', '<br>\n')
|
|
||||||
signature_md = bleach.linkify(bleach.clean(markdown.markdown(signature_md), tags=bleach.ALLOWED_TAGS + ['p', 'br']))
|
|
||||||
htmlctx['signature'] = signature_md
|
|
||||||
body_plain += signature
|
body_plain += signature
|
||||||
body_plain += "\r\n\r\n-- \r\n"
|
body_plain += "\r\n\r\n-- \r\n"
|
||||||
|
|
||||||
@@ -132,7 +124,6 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
|||||||
body_plain += _(
|
body_plain += _(
|
||||||
"You are receiving this email because you placed an order for {event}."
|
"You are receiving this email because you placed an order for {event}."
|
||||||
).format(event=event.name)
|
).format(event=event.name)
|
||||||
htmlctx['order'] = order
|
|
||||||
body_plain += "\r\n"
|
body_plain += "\r\n"
|
||||||
body_plain += _(
|
body_plain += _(
|
||||||
"You can view your order details at the following URL:\n{orderurl}."
|
"You can view your order details at the following URL:\n{orderurl}."
|
||||||
@@ -146,11 +137,15 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
|||||||
)
|
)
|
||||||
body_plain += "\r\n"
|
body_plain += "\r\n"
|
||||||
|
|
||||||
tpl = get_template('pretixbase/email/plainwrapper.html')
|
try:
|
||||||
body_html = tpl.render(htmlctx)
|
body_html = renderer.render(content_plain, signature, str(subject), order)
|
||||||
|
except:
|
||||||
|
logger.exception('Could not render HTML body')
|
||||||
|
body_html = None
|
||||||
|
|
||||||
send_task = mail_send_task.si(
|
send_task = mail_send_task.si(
|
||||||
to=[email],
|
to=[email],
|
||||||
|
bcc=bcc,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=body_plain,
|
body=body_plain,
|
||||||
html=body_html,
|
html=body_html,
|
||||||
@@ -176,7 +171,7 @@ def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sen
|
|||||||
order: int=None) -> bool:
|
order: int=None) -> bool:
|
||||||
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||||
if html is not None:
|
if html is not None:
|
||||||
email.attach_alternative(inline_css(html), "text/html")
|
email.attach_alternative(html, "text/html")
|
||||||
if invoices:
|
if invoices:
|
||||||
invoices = Invoice.objects.filter(pk__in=invoices)
|
invoices = Invoice.objects.filter(pk__in=invoices)
|
||||||
for inv in invoices:
|
for inv in invoices:
|
||||||
@@ -219,5 +214,4 @@ def render_mail(template, context):
|
|||||||
else:
|
else:
|
||||||
tpl = get_template(template)
|
tpl = get_template(template)
|
||||||
body = tpl.render(context)
|
body = tpl.render(context)
|
||||||
body_md = bleach.linkify(markdown_compile(body))
|
return body
|
||||||
return body, body_md
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
|
from inlinestyler.utils import inline_css
|
||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import LogEntry, NotificationSetting, User
|
from pretix.base.models import LogEntry, NotificationSetting, User
|
||||||
from pretix.base.notifications import Notification, get_all_notification_types
|
from pretix.base.notifications import Notification, get_all_notification_types
|
||||||
from pretix.base.services.async import ProfiledTask, TransactionAwareTask
|
|
||||||
from pretix.base.services.mail import mail_send_task
|
from pretix.base.services.mail import mail_send_task
|
||||||
|
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.helpers.urls import build_absolute_uri
|
from pretix.helpers.urls import build_absolute_uri
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ def send_notification_mail(notification: Notification, user: User):
|
|||||||
}
|
}
|
||||||
|
|
||||||
tpl_html = get_template('pretixbase/email/notification.html')
|
tpl_html = get_template('pretixbase/email/notification.html')
|
||||||
body_html = tpl_html.render(ctx)
|
body_html = inline_css(tpl_html.render(ctx))
|
||||||
tpl_plain = get_template('pretixbase/email/notification.txt')
|
tpl_plain = get_template('pretixbase/email/notification.txt')
|
||||||
body_plain = tpl_plain.render(ctx)
|
body_plain = tpl_plain.render(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections import Counter, namedtuple
|
from collections import Counter, namedtuple
|
||||||
@@ -13,6 +12,7 @@ from django.db import transaction
|
|||||||
from django.db.models import F, Max, Q, Sum
|
from django.db.models import F, Max, Q, Sum
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
@@ -21,29 +21,29 @@ from pretix.base.i18n import (
|
|||||||
LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language,
|
LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language,
|
||||||
)
|
)
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota,
|
CartPosition, Event, Item, ItemVariation, Order, OrderPayment,
|
||||||
User, Voucher,
|
OrderPosition, Quota, User, Voucher,
|
||||||
)
|
)
|
||||||
from pretix.base.models.event import SubEvent
|
from pretix.base.models.event import SubEvent
|
||||||
from pretix.base.models.orders import (
|
from pretix.base.models.orders import (
|
||||||
CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee,
|
CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee, OrderRefund,
|
||||||
generate_position_secret, generate_secret,
|
generate_position_secret, generate_secret,
|
||||||
)
|
)
|
||||||
from pretix.base.models.organizer import TeamAPIToken
|
from pretix.base.models.organizer import TeamAPIToken
|
||||||
from pretix.base.models.tax import TaxedPrice
|
from pretix.base.models.tax import TaxedPrice
|
||||||
from pretix.base.payment import BasePaymentProvider
|
from pretix.base.payment import BasePaymentProvider
|
||||||
from pretix.base.services.async import ProfiledTask
|
|
||||||
from pretix.base.services.invoices import (
|
from pretix.base.services.invoices import (
|
||||||
generate_cancellation, generate_invoice, invoice_qualified,
|
generate_cancellation, generate_invoice, invoice_qualified,
|
||||||
)
|
)
|
||||||
from pretix.base.services.locking import LockTimeoutException
|
from pretix.base.services.locking import LockTimeoutException
|
||||||
from pretix.base.services.mail import SendMailException
|
from pretix.base.services.mail import SendMailException
|
||||||
from pretix.base.services.pricing import get_price
|
from pretix.base.services.pricing import get_price
|
||||||
|
from pretix.base.services.tasks import ProfiledTask
|
||||||
from pretix.base.signals import (
|
from pretix.base.signals import (
|
||||||
allow_ticket_download, order_fee_calculation, order_paid, order_placed,
|
allow_ticket_download, order_fee_calculation, order_placed, periodic_task,
|
||||||
periodic_task,
|
|
||||||
)
|
)
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
|
from pretix.helpers.models import modelcopy
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
|
|
||||||
error_messages = {
|
error_messages = {
|
||||||
@@ -79,99 +79,8 @@ error_messages = {
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def mark_order_paid(order: Order, provider: str=None, info: str=None, date: datetime=None, manual: bool=None,
|
def mark_order_paid(*args, **kwargs):
|
||||||
force: bool=False, send_mail: bool=True, user: User=None, mail_text='',
|
raise NotImplementedError("This method is no longer supported since pretix 1.17.")
|
||||||
count_waitinglist=True, auth=None) -> Order:
|
|
||||||
"""
|
|
||||||
Marks an order as paid. This sets the payment provider, info and date and returns
|
|
||||||
the order object.
|
|
||||||
|
|
||||||
:param provider: The payment provider that marked this as paid
|
|
||||||
:type provider: str
|
|
||||||
:param info: The information to store in order.payment_info
|
|
||||||
:type info: str
|
|
||||||
:param date: The date the payment was received (if you pass ``None``, the current
|
|
||||||
time will be used).
|
|
||||||
:type date: datetime
|
|
||||||
:param force: Whether this payment should be marked as paid even if no remaining
|
|
||||||
quota is available (default: ``False``).
|
|
||||||
:type force: boolean
|
|
||||||
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
|
|
||||||
:type send_mail: boolean
|
|
||||||
:param user: The user that performed the change
|
|
||||||
:param mail_text: Additional text to be included in the email
|
|
||||||
:type mail_text: str
|
|
||||||
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
|
|
||||||
"""
|
|
||||||
if order.status == Order.STATUS_PAID:
|
|
||||||
return order
|
|
||||||
|
|
||||||
with order.event.lock() as now_dt:
|
|
||||||
can_be_paid = order._can_be_paid(count_waitinglist=count_waitinglist)
|
|
||||||
if not force and can_be_paid is not True:
|
|
||||||
raise Quota.QuotaExceededException(can_be_paid)
|
|
||||||
order.payment_provider = provider or order.payment_provider
|
|
||||||
order.payment_info = info or order.payment_info
|
|
||||||
order.payment_date = date or now_dt
|
|
||||||
if manual is not None:
|
|
||||||
order.payment_manual = manual
|
|
||||||
order.status = Order.STATUS_PAID
|
|
||||||
order.save()
|
|
||||||
|
|
||||||
order.log_action('pretix.event.order.paid', {
|
|
||||||
'provider': provider,
|
|
||||||
'info': info,
|
|
||||||
'date': date or now_dt,
|
|
||||||
'manual': manual,
|
|
||||||
'force': force
|
|
||||||
}, user=user, auth=auth)
|
|
||||||
order_paid.send(order.event, order=order)
|
|
||||||
|
|
||||||
invoice = None
|
|
||||||
if invoice_qualified(order):
|
|
||||||
invoices = order.invoices.filter(is_cancellation=False).count()
|
|
||||||
cancellations = order.invoices.filter(is_cancellation=True).count()
|
|
||||||
gen_invoice = (
|
|
||||||
(invoices == 0 and order.event.settings.get('invoice_generate') in ('True', 'paid')) or
|
|
||||||
0 < invoices <= cancellations
|
|
||||||
)
|
|
||||||
if gen_invoice:
|
|
||||||
invoice = generate_invoice(
|
|
||||||
order,
|
|
||||||
trigger_pdf=not send_mail or not order.event.settings.invoice_email_attachment
|
|
||||||
)
|
|
||||||
|
|
||||||
if send_mail:
|
|
||||||
with language(order.locale):
|
|
||||||
try:
|
|
||||||
invoice_name = order.invoice_address.name
|
|
||||||
invoice_company = order.invoice_address.company
|
|
||||||
except InvoiceAddress.DoesNotExist:
|
|
||||||
invoice_name = ""
|
|
||||||
invoice_company = ""
|
|
||||||
email_template = order.event.settings.mail_text_order_paid
|
|
||||||
email_context = {
|
|
||||||
'event': order.event.name,
|
|
||||||
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
|
||||||
'order': order.code,
|
|
||||||
'secret': order.secret
|
|
||||||
}),
|
|
||||||
'downloads': order.event.settings.get('ticket_download', as_type=bool),
|
|
||||||
'invoice_name': invoice_name,
|
|
||||||
'invoice_company': invoice_company,
|
|
||||||
'payment_info': mail_text
|
|
||||||
}
|
|
||||||
email_subject = _('Payment received for your order: %(code)s') % {'code': order.code}
|
|
||||||
try:
|
|
||||||
order.send_mail(
|
|
||||||
email_subject, email_template, email_context,
|
|
||||||
'pretix.event.order.email.order_paid', user,
|
|
||||||
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
|
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order paid email could not be sent')
|
|
||||||
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None):
|
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None):
|
||||||
@@ -215,7 +124,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
|
|||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def mark_order_refunded(order, user=None, api_token=None):
|
def mark_order_refunded(order, user=None, auth=None, api_token=None):
|
||||||
"""
|
"""
|
||||||
Mark this order as refunded. This sets the payment status and returns the order object.
|
Mark this order as refunded. This sets the payment status and returns the order object.
|
||||||
:param order: The order to change
|
:param order: The order to change
|
||||||
@@ -229,7 +138,7 @@ def mark_order_refunded(order, user=None, api_token=None):
|
|||||||
order.status = Order.STATUS_REFUNDED
|
order.status = Order.STATUS_REFUNDED
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
order.log_action('pretix.event.order.refunded', user=user, api_token=api_token)
|
order.log_action('pretix.event.order.refunded', user=user, auth=auth or api_token)
|
||||||
i = order.invoices.filter(is_cancellation=False).last()
|
i = order.invoices.filter(is_cancellation=False).last()
|
||||||
if i:
|
if i:
|
||||||
generate_cancellation(i)
|
generate_cancellation(i)
|
||||||
@@ -260,6 +169,142 @@ def mark_order_expired(order, user=None, auth=None):
|
|||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def approve_order(order, user=None, send_mail: bool=True, auth=None):
|
||||||
|
"""
|
||||||
|
Mark this order as approved
|
||||||
|
:param order: The order to change
|
||||||
|
:param user: The user that performed the change
|
||||||
|
"""
|
||||||
|
if not order.require_approval or not order.status == Order.STATUS_PENDING:
|
||||||
|
raise OrderError(_('This order is not pending approval.'))
|
||||||
|
|
||||||
|
order.require_approval = False
|
||||||
|
order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()]))
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
order.log_action('pretix.event.order.approved', user=user, auth=auth)
|
||||||
|
if order.total == Decimal('0.00'):
|
||||||
|
p = order.payments.create(
|
||||||
|
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||||
|
provider='free',
|
||||||
|
amount=0,
|
||||||
|
fee=None
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
p.confirm(send_mail=False, count_waitinglist=False, user=user, auth=auth)
|
||||||
|
except Quota.QuotaExceededException:
|
||||||
|
raise OrderError(error_messages['unavailable'])
|
||||||
|
|
||||||
|
invoice = order.invoices.last() # Might be generated by plugin already
|
||||||
|
if order.event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
|
||||||
|
if not invoice:
|
||||||
|
generate_invoice(
|
||||||
|
order,
|
||||||
|
trigger_pdf=not order.event.settings.invoice_email_attachment or not order.email
|
||||||
|
)
|
||||||
|
# send_mail will trigger PDF generation later
|
||||||
|
|
||||||
|
if send_mail:
|
||||||
|
try:
|
||||||
|
invoice_name = order.invoice_address.name
|
||||||
|
invoice_company = order.invoice_address.company
|
||||||
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
invoice_name = ""
|
||||||
|
invoice_company = ""
|
||||||
|
|
||||||
|
with language(order.locale):
|
||||||
|
if order.total == Decimal('0.00'):
|
||||||
|
email_template = order.event.settings.mail_text_order_free
|
||||||
|
email_subject = _('Order approved and confirmed: %(code)s') % {'code': order.code}
|
||||||
|
else:
|
||||||
|
email_template = order.event.settings.mail_text_order_approved
|
||||||
|
email_subject = _('Order approved and awaiting payment: %(code)s') % {'code': order.code}
|
||||||
|
|
||||||
|
email_context = {
|
||||||
|
'total': LazyNumber(order.total),
|
||||||
|
'currency': order.event.currency,
|
||||||
|
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
|
||||||
|
'date': LazyDate(order.expires),
|
||||||
|
'event': order.event.name,
|
||||||
|
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
||||||
|
'order': order.code,
|
||||||
|
'secret': order.secret
|
||||||
|
}),
|
||||||
|
'invoice_name': invoice_name,
|
||||||
|
'invoice_company': invoice_company,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
order.send_mail(
|
||||||
|
email_subject, email_template, email_context,
|
||||||
|
'pretix.event.order.email.order_approved', user
|
||||||
|
)
|
||||||
|
except SendMailException:
|
||||||
|
logger.exception('Order approved email could not be sent')
|
||||||
|
|
||||||
|
return order.pk
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||||
|
"""
|
||||||
|
Mark this order as canceled
|
||||||
|
:param order: The order to change
|
||||||
|
:param user: The user that performed the change
|
||||||
|
"""
|
||||||
|
if not order.require_approval or not order.status == Order.STATUS_PENDING:
|
||||||
|
raise OrderError(_('This order is not pending approval.'))
|
||||||
|
|
||||||
|
with order.event.lock():
|
||||||
|
order.status = Order.STATUS_CANCELED
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
order.log_action('pretix.event.order.denied', user=user, auth=auth, data={
|
||||||
|
'comment': comment
|
||||||
|
})
|
||||||
|
i = order.invoices.filter(is_cancellation=False).last()
|
||||||
|
if i:
|
||||||
|
generate_cancellation(i)
|
||||||
|
|
||||||
|
for position in order.positions.all():
|
||||||
|
if position.voucher:
|
||||||
|
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||||
|
|
||||||
|
if send_mail:
|
||||||
|
try:
|
||||||
|
invoice_name = order.invoice_address.name
|
||||||
|
invoice_company = order.invoice_address.company
|
||||||
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
invoice_name = ""
|
||||||
|
invoice_company = ""
|
||||||
|
email_template = order.event.settings.mail_text_order_denied
|
||||||
|
email_context = {
|
||||||
|
'total': LazyNumber(order.total),
|
||||||
|
'currency': order.event.currency,
|
||||||
|
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
|
||||||
|
'date': LazyDate(order.expires),
|
||||||
|
'event': order.event.name,
|
||||||
|
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
||||||
|
'order': order.code,
|
||||||
|
'secret': order.secret
|
||||||
|
}),
|
||||||
|
'comment': comment,
|
||||||
|
'invoice_name': invoice_name,
|
||||||
|
'invoice_company': invoice_company,
|
||||||
|
}
|
||||||
|
with language(order.locale):
|
||||||
|
email_subject = _('Order denied: %(code)s') % {'code': order.code}
|
||||||
|
try:
|
||||||
|
order.send_mail(
|
||||||
|
email_subject, email_template, email_context,
|
||||||
|
'pretix.event.order.email.order_denied', user
|
||||||
|
)
|
||||||
|
except SendMailException:
|
||||||
|
logger.exception('Order denied email could not be sent')
|
||||||
|
|
||||||
|
return order.pk
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_application=None):
|
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_application=None):
|
||||||
"""
|
"""
|
||||||
@@ -433,21 +478,26 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
|
|||||||
meta_info: dict, event: Event):
|
meta_info: dict, event: Event):
|
||||||
fees = []
|
fees = []
|
||||||
total = sum([c.price for c in positions])
|
total = sum([c.price for c in positions])
|
||||||
payment_fee = payment_provider.calculate_fee(total)
|
if payment_provider:
|
||||||
|
payment_fee = payment_provider.calculate_fee(total)
|
||||||
|
else:
|
||||||
|
payment_fee = 0
|
||||||
|
pf = None
|
||||||
if payment_fee:
|
if payment_fee:
|
||||||
fees.append(OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
|
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
|
||||||
internal_type=payment_provider.identifier))
|
internal_type=payment_provider.identifier)
|
||||||
|
fees.append(pf)
|
||||||
|
|
||||||
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total,
|
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total,
|
||||||
meta_info=meta_info, positions=positions):
|
meta_info=meta_info, positions=positions):
|
||||||
fees += resp
|
fees += resp
|
||||||
return fees
|
return fees, pf
|
||||||
|
|
||||||
|
|
||||||
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||||
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
|
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
|
||||||
meta_info: dict=None):
|
meta_info: dict=None):
|
||||||
fees = _get_fees(positions, payment_provider, address, meta_info, event)
|
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])
|
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -458,8 +508,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
|||||||
datetime=now_dt,
|
datetime=now_dt,
|
||||||
locale=locale,
|
locale=locale,
|
||||||
total=total,
|
total=total,
|
||||||
payment_provider=payment_provider.identifier,
|
|
||||||
meta_info=json.dumps(meta_info or {}),
|
meta_info=json.dumps(meta_info or {}),
|
||||||
|
require_approval=any(p.item.require_approval for p in positions)
|
||||||
)
|
)
|
||||||
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
|
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
|
||||||
order.save()
|
order.save()
|
||||||
@@ -479,6 +529,14 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
|||||||
fee.tax_rule = None # TODO: deprecate
|
fee.tax_rule = None # TODO: deprecate
|
||||||
fee.save()
|
fee.save()
|
||||||
|
|
||||||
|
if payment_provider:
|
||||||
|
order.payments.create(
|
||||||
|
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||||
|
provider=payment_provider.identifier,
|
||||||
|
amount=total,
|
||||||
|
fee=pf
|
||||||
|
)
|
||||||
|
|
||||||
OrderPosition.transform_cart_positions(positions, order)
|
OrderPosition.transform_cart_positions(positions, order)
|
||||||
order.log_action('pretix.event.order.placed')
|
order.log_action('pretix.event.order.placed')
|
||||||
if meta_info:
|
if meta_info:
|
||||||
@@ -493,9 +551,12 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
|||||||
email: str, locale: str, address: int, meta_info: dict=None):
|
email: str, locale: str, address: int, meta_info: dict=None):
|
||||||
|
|
||||||
event = Event.objects.get(id=event)
|
event = Event.objects.get(id=event)
|
||||||
pprov = event.get_payment_providers().get(payment_provider)
|
if payment_provider:
|
||||||
if not pprov:
|
pprov = event.get_payment_providers().get(payment_provider)
|
||||||
raise OrderError(error_messages['internal'])
|
if not pprov:
|
||||||
|
raise OrderError(error_messages['internal'])
|
||||||
|
else:
|
||||||
|
pprov = None
|
||||||
|
|
||||||
if email == settings.PRETIX_EMAIL_NONE_VALUE:
|
if email == settings.PRETIX_EMAIL_NONE_VALUE:
|
||||||
email = None
|
email = None
|
||||||
@@ -528,7 +589,10 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
|||||||
# send_mail will trigger PDF generation later
|
# send_mail will trigger PDF generation later
|
||||||
|
|
||||||
if order.email:
|
if order.email:
|
||||||
if order.payment_provider == 'free':
|
if order.require_approval:
|
||||||
|
email_template = event.settings.mail_text_order_placed_require_approval
|
||||||
|
log_entry = 'pretix.event.order.email.order_placed_require_approval'
|
||||||
|
elif payment_provider == 'free':
|
||||||
email_template = event.settings.mail_text_order_free
|
email_template = event.settings.mail_text_order_free
|
||||||
log_entry = 'pretix.event.order.email.order_free'
|
log_entry = 'pretix.event.order.email.order_free'
|
||||||
else:
|
else:
|
||||||
@@ -541,6 +605,12 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
|||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
invoice_name = ""
|
invoice_name = ""
|
||||||
invoice_company = ""
|
invoice_company = ""
|
||||||
|
|
||||||
|
if pprov:
|
||||||
|
payment_info = str(pprov.order_pending_mail_render(order))
|
||||||
|
else:
|
||||||
|
payment_info = None
|
||||||
|
|
||||||
email_context = {
|
email_context = {
|
||||||
'total': LazyNumber(order.total),
|
'total': LazyNumber(order.total),
|
||||||
'currency': event.currency,
|
'currency': event.currency,
|
||||||
@@ -551,7 +621,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
|||||||
'order': order.code,
|
'order': order.code,
|
||||||
'secret': order.secret
|
'secret': order.secret
|
||||||
}),
|
}),
|
||||||
'payment_info': str(pprov.order_pending_mail_render(order)),
|
'payment_info': payment_info,
|
||||||
'invoice_name': invoice_name,
|
'invoice_name': invoice_name,
|
||||||
'invoice_company': invoice_company,
|
'invoice_company': invoice_company,
|
||||||
}
|
}
|
||||||
@@ -572,7 +642,8 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
|||||||
def expire_orders(sender, **kwargs):
|
def expire_orders(sender, **kwargs):
|
||||||
eventcache = {}
|
eventcache = {}
|
||||||
|
|
||||||
for o in Order.objects.filter(expires__lt=now(), status=Order.STATUS_PENDING).select_related('event'):
|
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)
|
expire = eventcache.get(o.event.pk, None)
|
||||||
if expire is None:
|
if expire is None:
|
||||||
expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)
|
expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)
|
||||||
@@ -646,29 +717,29 @@ def send_download_reminders(sender, **kwargs):
|
|||||||
if not all([r for rr, r in allow_ticket_download.send(e, order=o)]):
|
if not all([r for rr, r in allow_ticket_download.send(e, order=o)]):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
o.download_reminder_sent = True
|
with language(o.locale):
|
||||||
o.save()
|
o.download_reminder_sent = True
|
||||||
email_template = e.settings.mail_text_download_reminder
|
o.save()
|
||||||
email_context = {
|
email_template = e.settings.mail_text_download_reminder
|
||||||
'event': o.event.name,
|
email_context = {
|
||||||
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
'event': o.event.name,
|
||||||
'order': o.code,
|
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
||||||
'secret': o.secret
|
'order': o.code,
|
||||||
}),
|
'secret': o.secret
|
||||||
}
|
}),
|
||||||
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
|
}
|
||||||
try:
|
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
|
||||||
o.send_mail(
|
try:
|
||||||
email_subject, email_template, email_context,
|
o.send_mail(
|
||||||
'pretix.event.order.email.download_reminder_sent'
|
email_subject, email_template, email_context,
|
||||||
)
|
'pretix.event.order.email.download_reminder_sent'
|
||||||
except SendMailException:
|
)
|
||||||
logger.exception('Reminder email could not be sent')
|
except SendMailException:
|
||||||
|
logger.exception('Reminder email could not be sent')
|
||||||
|
|
||||||
|
|
||||||
class OrderChangeManager:
|
class OrderChangeManager:
|
||||||
error_messages = {
|
error_messages = {
|
||||||
'free_to_paid': _('You cannot change a free order to a paid order.'),
|
|
||||||
'product_without_variation': _('You need to select a variation of the product.'),
|
'product_without_variation': _('You need to select a variation of the product.'),
|
||||||
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
|
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
|
||||||
'quota_missing': _('There is no quota defined that allows this operation.'),
|
'quota_missing': _('There is no quota defined that allows this operation.'),
|
||||||
@@ -677,8 +748,6 @@ class OrderChangeManager:
|
|||||||
'not_pending_or_paid': _('Only pending or paid orders can be changed.'),
|
'not_pending_or_paid': _('Only pending or paid orders can be changed.'),
|
||||||
'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however '
|
'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however '
|
||||||
'no quota is available.'),
|
'no quota is available.'),
|
||||||
'paid_price_change': _('Currently, paid orders can only be changed in a way that does not change the total '
|
|
||||||
'price of the order as partial payments or refunds are not yet supported.'),
|
|
||||||
'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'),
|
'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'),
|
||||||
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
|
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
|
||||||
'subevent_required': _('You need to choose a subevent for the new position.'),
|
'subevent_required': _('You need to choose a subevent for the new position.'),
|
||||||
@@ -691,9 +760,10 @@ class OrderChangeManager:
|
|||||||
SplitOperation = namedtuple('SplitOperation', ('position',))
|
SplitOperation = namedtuple('SplitOperation', ('position',))
|
||||||
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
|
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
|
||||||
|
|
||||||
def __init__(self, order: Order, user, notify=True):
|
def __init__(self, order: Order, user=None, auth=None, notify=True):
|
||||||
self.order = order
|
self.order = order
|
||||||
self.user = user
|
self.user = user
|
||||||
|
self.auth = auth
|
||||||
self.split_order = None
|
self.split_order = None
|
||||||
self._committed = False
|
self._committed = False
|
||||||
self._totaldiff = 0
|
self._totaldiff = 0
|
||||||
@@ -834,33 +904,44 @@ class OrderChangeManager:
|
|||||||
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
|
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
|
||||||
raise OrderError(self.error_messages['quota'].format(name=quota.name))
|
raise OrderError(self.error_messages['quota'].format(name=quota.name))
|
||||||
|
|
||||||
def _check_free_to_paid(self):
|
|
||||||
if self.order.total == Decimal('0.00') and self._totaldiff > 0:
|
|
||||||
raise OrderError(self.error_messages['free_to_paid'])
|
|
||||||
|
|
||||||
def _check_paid_price_change(self):
|
def _check_paid_price_change(self):
|
||||||
if self.order.status == Order.STATUS_PAID and self._totaldiff != 0:
|
if self.order.status == Order.STATUS_PAID and self._totaldiff > 0:
|
||||||
raise OrderError(self.error_messages['paid_price_change'])
|
self.order.status = Order.STATUS_PENDING
|
||||||
|
self.order.set_expires(
|
||||||
|
now(),
|
||||||
|
self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True))
|
||||||
|
)
|
||||||
|
self.order.save()
|
||||||
|
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0:
|
||||||
|
if self.order.pending_sum <= Decimal('0.00'):
|
||||||
|
self.order.status = Order.STATUS_PAID
|
||||||
|
self.order.save()
|
||||||
|
|
||||||
def _check_paid_to_free(self):
|
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)):
|
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)):
|
||||||
# if the order becomes free, mark it paid using the 'free' provider
|
# 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)
|
# 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)
|
# 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:
|
try:
|
||||||
mark_order_paid(
|
p.confirm(send_mail=False, count_waitinglist=False, user=self.user, auth=self.auth)
|
||||||
self.order, 'free', send_mail=False, count_waitinglist=False,
|
|
||||||
user=self.user
|
|
||||||
)
|
|
||||||
except Quota.QuotaExceededException:
|
except Quota.QuotaExceededException:
|
||||||
raise OrderError(self.error_messages['paid_to_free_exceeded'])
|
raise OrderError(self.error_messages['paid_to_free_exceeded'])
|
||||||
|
|
||||||
if self.split_order and self.split_order.total == 0:
|
if self.split_order and self.split_order.total == 0:
|
||||||
|
p = self.split_order.payments.create(
|
||||||
|
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||||
|
provider='free',
|
||||||
|
amount=0,
|
||||||
|
fee=None
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
mark_order_paid(
|
p.confirm(send_mail=False, count_waitinglist=False, user=self.user, auth=self.auth)
|
||||||
self.split_order, 'free', send_mail=False, count_waitinglist=False,
|
|
||||||
user=self.user
|
|
||||||
)
|
|
||||||
except Quota.QuotaExceededException:
|
except Quota.QuotaExceededException:
|
||||||
raise OrderError(self.error_messages['paid_to_free_exceeded'])
|
raise OrderError(self.error_messages['paid_to_free_exceeded'])
|
||||||
|
|
||||||
@@ -870,7 +951,7 @@ class OrderChangeManager:
|
|||||||
|
|
||||||
for op in self._operations:
|
for op in self._operations:
|
||||||
if isinstance(op, self.ItemOperation):
|
if isinstance(op, self.ItemOperation):
|
||||||
self.order.log_action('pretix.event.order.changed.item', user=self.user, data={
|
self.order.log_action('pretix.event.order.changed.item', user=self.user, auth=self.auth, data={
|
||||||
'position': op.position.pk,
|
'position': op.position.pk,
|
||||||
'positionid': op.position.positionid,
|
'positionid': op.position.positionid,
|
||||||
'old_item': op.position.item.pk,
|
'old_item': op.position.item.pk,
|
||||||
@@ -889,7 +970,7 @@ class OrderChangeManager:
|
|||||||
op.position.tax_rule = op.item.tax_rule
|
op.position.tax_rule = op.item.tax_rule
|
||||||
op.position.save()
|
op.position.save()
|
||||||
elif isinstance(op, self.SubeventOperation):
|
elif isinstance(op, self.SubeventOperation):
|
||||||
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, data={
|
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
|
||||||
'position': op.position.pk,
|
'position': op.position.pk,
|
||||||
'positionid': op.position.positionid,
|
'positionid': op.position.positionid,
|
||||||
'old_subevent': op.position.subevent.pk,
|
'old_subevent': op.position.subevent.pk,
|
||||||
@@ -904,7 +985,7 @@ class OrderChangeManager:
|
|||||||
op.position.tax_rule = op.position.item.tax_rule
|
op.position.tax_rule = op.position.item.tax_rule
|
||||||
op.position.save()
|
op.position.save()
|
||||||
elif isinstance(op, self.PriceOperation):
|
elif isinstance(op, self.PriceOperation):
|
||||||
self.order.log_action('pretix.event.order.changed.price', user=self.user, data={
|
self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={
|
||||||
'position': op.position.pk,
|
'position': op.position.pk,
|
||||||
'positionid': op.position.positionid,
|
'positionid': op.position.positionid,
|
||||||
'old_price': op.position.price,
|
'old_price': op.position.price,
|
||||||
@@ -918,7 +999,7 @@ class OrderChangeManager:
|
|||||||
op.position.save()
|
op.position.save()
|
||||||
elif isinstance(op, self.CancelOperation):
|
elif isinstance(op, self.CancelOperation):
|
||||||
for opa in op.position.addons.all():
|
for opa in op.position.addons.all():
|
||||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
|
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
|
||||||
'position': opa.pk,
|
'position': opa.pk,
|
||||||
'positionid': opa.positionid,
|
'positionid': opa.positionid,
|
||||||
'old_item': opa.item.pk,
|
'old_item': opa.item.pk,
|
||||||
@@ -926,7 +1007,7 @@ class OrderChangeManager:
|
|||||||
'addon_to': opa.addon_to_id,
|
'addon_to': opa.addon_to_id,
|
||||||
'old_price': opa.price,
|
'old_price': opa.price,
|
||||||
})
|
})
|
||||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
|
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
|
||||||
'position': op.position.pk,
|
'position': op.position.pk,
|
||||||
'positionid': op.position.positionid,
|
'positionid': op.position.positionid,
|
||||||
'old_item': op.position.item.pk,
|
'old_item': op.position.item.pk,
|
||||||
@@ -943,7 +1024,7 @@ class OrderChangeManager:
|
|||||||
positionid=nextposid, subevent=op.subevent
|
positionid=nextposid, subevent=op.subevent
|
||||||
)
|
)
|
||||||
nextposid += 1
|
nextposid += 1
|
||||||
self.order.log_action('pretix.event.order.changed.add', user=self.user, data={
|
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
|
||||||
'position': pos.pk,
|
'position': pos.pk,
|
||||||
'item': op.item.pk,
|
'item': op.item.pk,
|
||||||
'variation': op.variation.pk if op.variation else None,
|
'variation': op.variation.pk if op.variation else None,
|
||||||
@@ -959,7 +1040,7 @@ class OrderChangeManager:
|
|||||||
op.position.save()
|
op.position.save()
|
||||||
CachedTicket.objects.filter(order_position__order=self.order).delete()
|
CachedTicket.objects.filter(order_position__order=self.order).delete()
|
||||||
CachedCombinedTicket.objects.filter(order=self.order).delete()
|
CachedCombinedTicket.objects.filter(order=self.order).delete()
|
||||||
self.order.log_action('pretix.event.order.changed.secret', user=self.user, data={
|
self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={
|
||||||
'position': op.position.pk,
|
'position': op.position.pk,
|
||||||
'positionid': op.position.positionid,
|
'positionid': op.position.positionid,
|
||||||
})
|
})
|
||||||
@@ -974,12 +1055,12 @@ class OrderChangeManager:
|
|||||||
split_order.datetime = now()
|
split_order.datetime = now()
|
||||||
split_order.secret = generate_secret()
|
split_order.secret = generate_secret()
|
||||||
split_order.save()
|
split_order.save()
|
||||||
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, data={
|
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
|
||||||
'original_order': self.order.code
|
'original_order': self.order.code
|
||||||
})
|
})
|
||||||
|
|
||||||
for op in split_positions:
|
for op in split_positions:
|
||||||
self.order.log_action('pretix.event.order.changed.split', user=self.user, data={
|
self.order.log_action('pretix.event.order.changed.split', user=self.user, auth=self.auth, data={
|
||||||
'position': op.pk,
|
'position': op.pk,
|
||||||
'positionid': op.positionid,
|
'positionid': op.positionid,
|
||||||
'old_item': op.item.pk,
|
'old_item': op.item.pk,
|
||||||
@@ -992,7 +1073,7 @@ class OrderChangeManager:
|
|||||||
op.save()
|
op.save()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ia = copy.copy(self.order.invoice_address)
|
ia = modelcopy(self.order.invoice_address)
|
||||||
ia.pk = None
|
ia.pk = None
|
||||||
ia.order = split_order
|
ia.order = split_order
|
||||||
ia.save()
|
ia.save()
|
||||||
@@ -1001,7 +1082,11 @@ class OrderChangeManager:
|
|||||||
|
|
||||||
split_order.total = sum([p.price for p in split_positions])
|
split_order.total = sum([p.price for p in split_positions])
|
||||||
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
|
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
|
||||||
payment_fee = self._get_payment_provider().calculate_fee(split_order.total)
|
pp = self._get_payment_provider()
|
||||||
|
if pp:
|
||||||
|
payment_fee = pp.calculate_fee(split_order.total)
|
||||||
|
else:
|
||||||
|
payment_fee = Decimal('0.00')
|
||||||
fee = split_order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0]
|
fee = split_order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0]
|
||||||
fee.value = payment_fee
|
fee.value = payment_fee
|
||||||
fee._calculate_tax()
|
fee._calculate_tax()
|
||||||
@@ -1012,7 +1097,7 @@ class OrderChangeManager:
|
|||||||
split_order.total += fee.value
|
split_order.total += fee.value
|
||||||
|
|
||||||
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
|
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
|
||||||
new_fee = copy.copy(fee)
|
new_fee = modelcopy(fee)
|
||||||
new_fee.pk = None
|
new_fee.pk = None
|
||||||
new_fee.order = split_order
|
new_fee.order = split_order
|
||||||
split_order.total += new_fee.value
|
split_order.total += new_fee.value
|
||||||
@@ -1020,41 +1105,89 @@ class OrderChangeManager:
|
|||||||
|
|
||||||
split_order.save()
|
split_order.save()
|
||||||
|
|
||||||
|
if split_order.status == Order.STATUS_PAID:
|
||||||
|
split_order.payments.create(
|
||||||
|
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||||
|
amount=split_order.total,
|
||||||
|
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,
|
||||||
|
execution_date=now(),
|
||||||
|
provider='offsetting',
|
||||||
|
info=json.dumps({'orders': [split_order.code]})
|
||||||
|
)
|
||||||
|
|
||||||
if split_order.total != Decimal('0.00') and self.order.invoices.filter(is_cancellation=False).last():
|
if split_order.total != Decimal('0.00') and self.order.invoices.filter(is_cancellation=False).last():
|
||||||
generate_invoice(split_order)
|
generate_invoice(split_order)
|
||||||
return split_order
|
return split_order
|
||||||
|
|
||||||
def _recalculate_total_and_payment_fee(self):
|
@cached_property
|
||||||
self.order.total = sum([p.price for p in self.order.positions.all()])
|
def open_payment(self):
|
||||||
|
lp = self.order.payments.last()
|
||||||
|
if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||||
|
OrderPayment.PAYMENT_STATE_REFUNDED):
|
||||||
|
return lp
|
||||||
|
|
||||||
if self.order.status != Order.STATUS_PAID:
|
@cached_property
|
||||||
# Do not change payment fees of paid orders
|
def completed_payment_sum(self):
|
||||||
payment_fee = Decimal('0.00')
|
payment_sum = self.order.payments.filter(
|
||||||
if self.order.total != 0:
|
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
|
||||||
prov = self._get_payment_provider()
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||||
|
refund_sum = self.order.refunds.filter(
|
||||||
|
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_DONE)
|
||||||
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||||
|
return payment_sum - refund_sum
|
||||||
|
|
||||||
|
def _recalculate_total_and_payment_fee(self):
|
||||||
|
total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
|
||||||
|
payment_fee = Decimal('0.00')
|
||||||
|
if self.open_payment:
|
||||||
|
current_fee = Decimal('0.00')
|
||||||
|
fee = None
|
||||||
|
if self.open_payment.fee:
|
||||||
|
fee = self.open_payment.fee
|
||||||
|
current_fee = self.open_payment.fee.value
|
||||||
|
total -= current_fee
|
||||||
|
|
||||||
|
if self.order.pending_sum - current_fee != 0:
|
||||||
|
prov = self.open_payment.payment_provider
|
||||||
if prov:
|
if prov:
|
||||||
payment_fee = prov.calculate_fee(self.order.total)
|
payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
|
||||||
|
|
||||||
if payment_fee:
|
if payment_fee:
|
||||||
fee = self.order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0]
|
fee = fee or OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, order=self.order)
|
||||||
fee.value = payment_fee
|
fee.value = payment_fee
|
||||||
fee._calculate_tax()
|
fee._calculate_tax()
|
||||||
fee.save()
|
fee.save()
|
||||||
else:
|
if not self.open_payment.fee:
|
||||||
self.order.fees.filter(fee_type=OrderFee.FEE_TYPE_PAYMENT).delete()
|
self.open_payment.fee = fee
|
||||||
|
self.open_payment.save()
|
||||||
|
elif fee:
|
||||||
|
fee.delete()
|
||||||
|
|
||||||
self.order.total += sum([f.value for f in self.order.fees.all()])
|
self.order.total = total + payment_fee
|
||||||
self.order.save()
|
self.order.save()
|
||||||
|
|
||||||
def _payment_fee_diff(self):
|
def _payment_fee_diff(self):
|
||||||
prov = self._get_payment_provider()
|
total = self.order.total + self._totaldiff
|
||||||
if self.order.status != Order.STATUS_PAID and prov:
|
if self.open_payment:
|
||||||
# payment fees of paid orders do not change
|
current_fee = Decimal('0.00')
|
||||||
old_fee = OrderFee.objects.filter(order=self.order, fee_type=OrderFee.FEE_TYPE_PAYMENT).aggregate(s=Sum('value'))['s'] or 0
|
if self.open_payment and self.open_payment.fee:
|
||||||
new_total = sum([p.price for p in self.order.positions.all()]) + self._totaldiff
|
current_fee = self.open_payment.fee.value
|
||||||
if new_total != 0:
|
total -= current_fee
|
||||||
new_fee = prov.calculate_fee(new_total)
|
|
||||||
self._totaldiff += new_fee - old_fee
|
# Do not change payment fees of paid orders
|
||||||
|
payment_fee = Decimal('0.00')
|
||||||
|
if 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)
|
||||||
|
|
||||||
|
self._totaldiff += payment_fee - current_fee
|
||||||
|
|
||||||
def _reissue_invoice(self):
|
def _reissue_invoice(self):
|
||||||
i = self.order.invoices.filter(is_cancellation=False).last()
|
i = self.order.invoices.filter(is_cancellation=False).last()
|
||||||
@@ -1097,7 +1230,7 @@ class OrderChangeManager:
|
|||||||
try:
|
try:
|
||||||
order.send_mail(
|
order.send_mail(
|
||||||
email_subject, email_template, email_context,
|
email_subject, email_template, email_context,
|
||||||
'pretix.event.order.email.order_changed', self.user
|
'pretix.event.order.email.order_changed', self.user, auth=self.auth
|
||||||
)
|
)
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
logger.exception('Order changed email could not be sent')
|
logger.exception('Order changed email could not be sent')
|
||||||
@@ -1119,8 +1252,6 @@ class OrderChangeManager:
|
|||||||
with self.order.event.lock():
|
with self.order.event.lock():
|
||||||
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||||
raise OrderError(self.error_messages['not_pending_or_paid'])
|
raise OrderError(self.error_messages['not_pending_or_paid'])
|
||||||
self._check_free_to_paid()
|
|
||||||
self._check_paid_price_change()
|
|
||||||
self._check_quotas()
|
self._check_quotas()
|
||||||
self._check_complete_cancel()
|
self._check_complete_cancel()
|
||||||
self._perform_operations()
|
self._perform_operations()
|
||||||
@@ -1128,6 +1259,7 @@ class OrderChangeManager:
|
|||||||
self._reissue_invoice()
|
self._reissue_invoice()
|
||||||
self._clear_tickets_cache()
|
self._clear_tickets_cache()
|
||||||
self.order.touch()
|
self.order.touch()
|
||||||
|
self._check_paid_price_change()
|
||||||
self._check_paid_to_free()
|
self._check_paid_to_free()
|
||||||
|
|
||||||
if self.notify:
|
if self.notify:
|
||||||
@@ -1143,9 +1275,12 @@ class OrderChangeManager:
|
|||||||
CachedCombinedTicket.objects.filter(order=self.split_order).delete()
|
CachedCombinedTicket.objects.filter(order=self.split_order).delete()
|
||||||
|
|
||||||
def _get_payment_provider(self):
|
def _get_payment_provider(self):
|
||||||
pprov = self.order.event.get_payment_providers().get(self.order.payment_provider)
|
lp = self.order.payments.last()
|
||||||
|
if not lp:
|
||||||
|
return None
|
||||||
|
pprov = lp.payment_provider
|
||||||
if not pprov:
|
if not pprov:
|
||||||
raise OrderError(error_messages['internal'])
|
return None
|
||||||
return pprov
|
return pprov
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.utils.timezone import now
|
|||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from pretix.base.models import CachedFile, Event, cachedfile_name
|
from pretix.base.models import CachedFile, Event, cachedfile_name
|
||||||
from pretix.base.services.async import ProfiledTask
|
from pretix.base.services.tasks import ProfiledTask
|
||||||
from pretix.base.shredder import ShredError
|
from pretix.base.shredder import ShredError
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
|
|
||||||
|
|||||||
@@ -88,53 +88,40 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
|||||||
'item', 'variation', 'order__status'
|
'item', 'variation', 'order__status'
|
||||||
).annotate(cnt=Count('id'), price=Sum('price'), tax_value=Sum('tax_value')).order_by()
|
).annotate(cnt=Count('id'), price=Sum('price'), tax_value=Sum('tax_value')).order_by()
|
||||||
|
|
||||||
num_canceled = {
|
states = {
|
||||||
(p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value'])
|
'canceled': Order.STATUS_CANCELED,
|
||||||
for p in counters if p['order__status'] == Order.STATUS_CANCELED
|
'refunded': Order.STATUS_REFUNDED,
|
||||||
|
'paid': Order.STATUS_PAID,
|
||||||
|
'pending': Order.STATUS_PENDING,
|
||||||
|
'expired': Order.STATUS_EXPIRED,
|
||||||
}
|
}
|
||||||
num_refunded = {
|
num = {}
|
||||||
(p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value'])
|
for l, s in states.items():
|
||||||
for p in counters if p['order__status'] == Order.STATUS_REFUNDED
|
num[l] = {
|
||||||
}
|
(p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value'])
|
||||||
num_paid = {
|
for p in counters if p['order__status'] == s
|
||||||
(p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value'])
|
}
|
||||||
for p in counters if p['order__status'] == Order.STATUS_PAID
|
|
||||||
}
|
num['total'] = dictsum(num['pending'], num['paid'])
|
||||||
num_pending = {
|
|
||||||
(p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value'])
|
|
||||||
for p in counters if p['order__status'] == Order.STATUS_PENDING
|
|
||||||
}
|
|
||||||
num_expired = {
|
|
||||||
(p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value'])
|
|
||||||
for p in counters if p['order__status'] == Order.STATUS_EXPIRED
|
|
||||||
}
|
|
||||||
num_total = dictsum(num_pending, num_paid)
|
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
item.all_variations = list(item.variations.all())
|
item.all_variations = list(item.variations.all())
|
||||||
item.has_variations = (len(item.all_variations) > 0)
|
item.has_variations = (len(item.all_variations) > 0)
|
||||||
|
item.num = {}
|
||||||
if item.has_variations:
|
if item.has_variations:
|
||||||
for var in item.all_variations:
|
for var in item.all_variations:
|
||||||
variid = var.id
|
variid = var.id
|
||||||
var.num_total = num_total.get((item.id, variid), (0, 0, 0))
|
var.num = {}
|
||||||
var.num_pending = num_pending.get((item.id, variid), (0, 0, 0))
|
for l in states.keys():
|
||||||
var.num_expired = num_expired.get((item.id, variid), (0, 0, 0))
|
var.num[l] = num[l].get((item.id, variid), (0, 0, 0))
|
||||||
var.num_canceled = num_canceled.get((item.id, variid), (0, 0, 0))
|
var.num['total'] = num['total'].get((item.id, variid), (0, 0, 0))
|
||||||
var.num_refunded = num_refunded.get((item.id, variid), (0, 0, 0))
|
for l in states.keys():
|
||||||
var.num_paid = num_paid.get((item.id, variid), (0, 0, 0))
|
item.num[l] = tuplesum(var.num[l] for var in item.all_variations)
|
||||||
item.num_total = tuplesum(var.num_total for var in item.all_variations)
|
item.num['total'] = tuplesum(var.num['total'] for var in item.all_variations)
|
||||||
item.num_pending = tuplesum(var.num_pending for var in item.all_variations)
|
|
||||||
item.num_expired = tuplesum(var.num_expired for var in item.all_variations)
|
|
||||||
item.num_canceled = tuplesum(var.num_canceled for var in item.all_variations)
|
|
||||||
item.num_refunded = tuplesum(var.num_refunded for var in item.all_variations)
|
|
||||||
item.num_paid = tuplesum(var.num_paid for var in item.all_variations)
|
|
||||||
else:
|
else:
|
||||||
item.num_total = num_total.get((item.id, None), (0, 0, 0))
|
for l in states.keys():
|
||||||
item.num_pending = num_pending.get((item.id, None), (0, 0, 0))
|
item.num[l] = num[l].get((item.id, None), (0, 0, 0))
|
||||||
item.num_expired = num_expired.get((item.id, None), (0, 0, 0))
|
item.num['total'] = num['total'].get((item.id, None), (0, 0, 0))
|
||||||
item.num_canceled = num_canceled.get((item.id, None), (0, 0, 0))
|
|
||||||
item.num_refunded = num_refunded.get((item.id, None), (0, 0, 0))
|
|
||||||
item.num_paid = num_paid.get((item.id, None), (0, 0, 0))
|
|
||||||
|
|
||||||
nonecat = ItemCategory(name=_('Uncategorized'))
|
nonecat = ItemCategory(name=_('Uncategorized'))
|
||||||
# Regroup those by category
|
# Regroup those by category
|
||||||
@@ -151,12 +138,10 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
|||||||
)
|
)
|
||||||
|
|
||||||
for c in items_by_category:
|
for c in items_by_category:
|
||||||
c[0].num_total = tuplesum(item.num_total for item in c[1])
|
c[0].num = {}
|
||||||
c[0].num_pending = tuplesum(item.num_pending for item in c[1])
|
for l in states.keys():
|
||||||
c[0].num_expired = tuplesum(item.num_expired for item in c[1])
|
c[0].num[l] = tuplesum(item.num[l] for item in c[1])
|
||||||
c[0].num_canceled = tuplesum(item.num_canceled for item in c[1])
|
c[0].num['total'] = tuplesum(item.num['total'] for item in c[1])
|
||||||
c[0].num_refunded = tuplesum(item.num_refunded for item in c[1])
|
|
||||||
c[0].num_paid = tuplesum(item.num_paid for item in c[1])
|
|
||||||
|
|
||||||
# Payment fees
|
# Payment fees
|
||||||
payment_cat_obj = DummyObject()
|
payment_cat_obj = DummyObject()
|
||||||
@@ -170,27 +155,12 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
|||||||
'fee_type', 'internal_type', 'order__status'
|
'fee_type', 'internal_type', 'order__status'
|
||||||
).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by()
|
).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by()
|
||||||
|
|
||||||
num_canceled = {
|
for l, s in states.items():
|
||||||
(o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value'])
|
num[l] = {
|
||||||
for o in counters if o['order__status'] == Order.STATUS_CANCELED
|
(o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value'])
|
||||||
}
|
for o in counters if o['order__status'] == s
|
||||||
num_refunded = {
|
}
|
||||||
(o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value'])
|
num['total'] = dictsum(num['pending'], num['paid'])
|
||||||
for o in counters if o['order__status'] == Order.STATUS_REFUNDED
|
|
||||||
}
|
|
||||||
num_pending = {
|
|
||||||
(o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value'])
|
|
||||||
for o in counters if o['order__status'] == Order.STATUS_PENDING
|
|
||||||
}
|
|
||||||
num_expired = {
|
|
||||||
(o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value'])
|
|
||||||
for o in counters if o['order__status'] == Order.STATUS_EXPIRED
|
|
||||||
}
|
|
||||||
num_paid = {
|
|
||||||
(o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value'])
|
|
||||||
for o in counters if o['order__status'] == Order.STATUS_PAID
|
|
||||||
}
|
|
||||||
num_total = dictsum(num_pending, num_paid)
|
|
||||||
|
|
||||||
provider_names = {
|
provider_names = {
|
||||||
k: v.verbose_name
|
k: v.verbose_name
|
||||||
@@ -198,7 +168,7 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
|||||||
}
|
}
|
||||||
names = dict(OrderFee.FEE_TYPES)
|
names = dict(OrderFee.FEE_TYPES)
|
||||||
|
|
||||||
for pprov, total in sorted(num_total.items(), key=lambda i: i[0]):
|
for pprov, total in sorted(num['total'].items(), key=lambda i: i[0]):
|
||||||
ppobj = DummyObject()
|
ppobj = DummyObject()
|
||||||
if pprov[0] == OrderFee.FEE_TYPE_PAYMENT:
|
if pprov[0] == OrderFee.FEE_TYPE_PAYMENT:
|
||||||
ppobj.name = '{} - {}'.format(names[pprov[0]], provider_names.get(pprov[1], pprov[1]))
|
ppobj.name = '{} - {}'.format(names[pprov[0]], provider_names.get(pprov[1], pprov[1]))
|
||||||
@@ -212,43 +182,29 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
|||||||
ppobj.name = '{} - {}'.format(names[pprov[0]], name)
|
ppobj.name = '{} - {}'.format(names[pprov[0]], name)
|
||||||
ppobj.provider = pprov[1]
|
ppobj.provider = pprov[1]
|
||||||
ppobj.has_variations = False
|
ppobj.has_variations = False
|
||||||
ppobj.num_total = total
|
ppobj.num = {}
|
||||||
ppobj.num_canceled = num_canceled.get(pprov, (0, 0, 0))
|
for l in states.keys():
|
||||||
ppobj.num_refunded = num_refunded.get(pprov, (0, 0, 0))
|
ppobj.num[l] = num[l].get(pprov, (0, 0, 0))
|
||||||
ppobj.num_expired = num_expired.get(pprov, (0, 0, 0))
|
ppobj.num['total'] = total
|
||||||
ppobj.num_pending = num_pending.get(pprov, (0, 0, 0))
|
|
||||||
ppobj.num_paid = num_paid.get(pprov, (0, 0, 0))
|
|
||||||
payment_items.append(ppobj)
|
payment_items.append(ppobj)
|
||||||
|
|
||||||
payment_cat_obj.num_total = (
|
payment_cat_obj.num = {}
|
||||||
Dontsum(''), sum(i.num_total[1] for i in payment_items), sum(i.num_total[2] for i in payment_items)
|
for l in states.keys():
|
||||||
)
|
payment_cat_obj.num[l] = (
|
||||||
payment_cat_obj.num_canceled = (
|
Dontsum(''), sum(i.num[l][1] for i in payment_items), sum(i.num[l][2] for i in payment_items)
|
||||||
Dontsum(''), sum(i.num_canceled[1] for i in payment_items), sum(i.num_canceled[2] for i in payment_items)
|
)
|
||||||
)
|
payment_cat_obj.num['total'] = (
|
||||||
payment_cat_obj.num_refunded = (
|
Dontsum(''), sum(i.num['total'][1] for i in payment_items), sum(i.num['total'][2] for i in payment_items)
|
||||||
Dontsum(''), sum(i.num_refunded[1] for i in payment_items), sum(i.num_refunded[2] for i in payment_items)
|
|
||||||
)
|
|
||||||
payment_cat_obj.num_expired = (
|
|
||||||
Dontsum(''), sum(i.num_expired[1] for i in payment_items), sum(i.num_expired[2] for i in payment_items)
|
|
||||||
)
|
|
||||||
payment_cat_obj.num_pending = (
|
|
||||||
Dontsum(''), sum(i.num_pending[1] for i in payment_items), sum(i.num_pending[2] for i in payment_items)
|
|
||||||
)
|
|
||||||
payment_cat_obj.num_paid = (
|
|
||||||
Dontsum(''), sum(i.num_paid[1] for i in payment_items), sum(i.num_paid[2] for i in payment_items)
|
|
||||||
)
|
)
|
||||||
payment_cat = (payment_cat_obj, payment_items)
|
payment_cat = (payment_cat_obj, payment_items)
|
||||||
|
any_payment = any(payment_cat_obj.num[s][1] for s in states.keys())
|
||||||
items_by_category.append(payment_cat)
|
if any_payment:
|
||||||
|
items_by_category.append(payment_cat)
|
||||||
|
|
||||||
total = {
|
total = {
|
||||||
'num_total': tuplesum(c.num_total for c, i in items_by_category),
|
'num': {'total': tuplesum(c.num['total'] for c, i in items_by_category)}
|
||||||
'num_pending': tuplesum(c.num_pending for c, i in items_by_category),
|
|
||||||
'num_expired': tuplesum(c.num_expired for c, i in items_by_category),
|
|
||||||
'num_canceled': tuplesum(c.num_canceled for c, i in items_by_category),
|
|
||||||
'num_refunded': tuplesum(c.num_refunded for c, i in items_by_category),
|
|
||||||
'num_paid': tuplesum(c.num_paid for c, i in items_by_category)
|
|
||||||
}
|
}
|
||||||
|
for l in states.keys():
|
||||||
|
total['num'][l] = tuplesum(c.num[l] for c, i in items_by_category)
|
||||||
|
|
||||||
return items_by_category, total
|
return items_by_category, total
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pretix.base.models import (
|
|||||||
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order,
|
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order,
|
||||||
OrderPosition,
|
OrderPosition,
|
||||||
)
|
)
|
||||||
from pretix.base.services.async import ProfiledTask
|
from pretix.base.services.tasks import ProfiledTask
|
||||||
from pretix.base.signals import register_ticket_outputs
|
from pretix.base.signals import register_ticket_outputs
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.helpers.database import rolledback_transaction
|
from pretix.helpers.database import rolledback_transaction
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.dispatch import receiver
|
|||||||
|
|
||||||
from pretix.base.models import Event, User, WaitingListEntry
|
from pretix.base.models import Event, User, WaitingListEntry
|
||||||
from pretix.base.models.waitinglist import WaitingListException
|
from pretix.base.models.waitinglist import WaitingListException
|
||||||
from pretix.base.services.async import ProfiledTask
|
from pretix.base.services.tasks import ProfiledTask
|
||||||
from pretix.base.signals import periodic_task
|
from pretix.base.signals import periodic_task
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
|
|
||||||
@@ -22,7 +22,9 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
|
|||||||
|
|
||||||
qs = WaitingListEntry.objects.filter(
|
qs = WaitingListEntry.objects.filter(
|
||||||
event=event, voucher__isnull=True
|
event=event, voucher__isnull=True
|
||||||
).select_related('item', 'variation').prefetch_related('item__quotas', 'variation__quotas').order_by('created')
|
).select_related('item', 'variation').prefetch_related(
|
||||||
|
'item__quotas', 'variation__quotas'
|
||||||
|
).order_by('-priority', 'created')
|
||||||
|
|
||||||
if subevent_id and event.has_subevents:
|
if subevent_id and event.has_subevents:
|
||||||
subevent = event.subevents.get(id=subevent_id)
|
subevent = event.subevents.get(id=subevent_id)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
@@ -7,7 +8,6 @@ from django.db.models import Model
|
|||||||
from django.utils.translation import ugettext_noop
|
from django.utils.translation import ugettext_noop
|
||||||
from hierarkey.models import GlobalSettingsBase, Hierarkey
|
from hierarkey.models import GlobalSettingsBase, Hierarkey
|
||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pretix.base.models.tax import TaxRule
|
from pretix.base.models.tax import TaxRule
|
||||||
from pretix.base.reldate import RelativeDateWrapper
|
from pretix.base.reldate import RelativeDateWrapper
|
||||||
@@ -57,6 +57,10 @@ DEFAULTS = {
|
|||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
},
|
},
|
||||||
|
'invoice_address_company_required': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
},
|
||||||
'invoice_address_vatid': {
|
'invoice_address_vatid': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
@@ -221,10 +225,18 @@ DEFAULTS = {
|
|||||||
'default': None,
|
'default': None,
|
||||||
'type': LazyI18nString
|
'type': LazyI18nString
|
||||||
},
|
},
|
||||||
|
'mail_html_renderer': {
|
||||||
|
'default': 'classic',
|
||||||
|
'type': str
|
||||||
|
},
|
||||||
'mail_prefix': {
|
'mail_prefix': {
|
||||||
'default': None,
|
'default': None,
|
||||||
'type': str
|
'type': str
|
||||||
},
|
},
|
||||||
|
'mail_bcc': {
|
||||||
|
'default': None,
|
||||||
|
'type': str
|
||||||
|
},
|
||||||
'mail_from': {
|
'mail_from': {
|
||||||
'default': settings.MAIL_FROM,
|
'default': settings.MAIL_FROM,
|
||||||
'type': str
|
'type': str
|
||||||
@@ -262,8 +274,22 @@ Your {event} team"""))
|
|||||||
'type': LazyI18nString,
|
'type': LazyI18nString,
|
||||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
||||||
|
|
||||||
we successfully received your order for {event}. As you only ordered
|
your order for {event} was successful. As you only ordered free products,
|
||||||
free products, no payment is required.
|
no payment is required.
|
||||||
|
|
||||||
|
You can change your order details and view the status of your order at
|
||||||
|
{url}
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Your {event} team"""))
|
||||||
|
},
|
||||||
|
'mail_text_order_placed_require_approval': {
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
||||||
|
|
||||||
|
we successfully received your order for {event}. Since you ordered
|
||||||
|
a product that requires approval by the event organizer, we ask you to
|
||||||
|
be patient and wait for our next email.
|
||||||
|
|
||||||
You can change your order details and view the status of your order at
|
You can change your order details and view the status of your order at
|
||||||
{url}
|
{url}
|
||||||
@@ -362,6 +388,37 @@ your order {code} for {event} has been canceled.
|
|||||||
You can view the details of your order at
|
You can view the details of your order at
|
||||||
{url}
|
{url}
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Your {event} team"""))
|
||||||
|
},
|
||||||
|
'mail_text_order_approved': {
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
||||||
|
|
||||||
|
we approved your order for {event} and will be happy to welcome you
|
||||||
|
at our event.
|
||||||
|
|
||||||
|
Please continue by paying for your order before {date}.
|
||||||
|
|
||||||
|
You can select a payment method and perform the payment here:
|
||||||
|
|
||||||
|
{url}
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Your {event} team"""))
|
||||||
|
},
|
||||||
|
'mail_text_order_denied': {
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
||||||
|
|
||||||
|
unfortunately, we denied your order request for {event}.
|
||||||
|
|
||||||
|
{comment}
|
||||||
|
|
||||||
|
You can view the details of your order here:
|
||||||
|
|
||||||
|
{url}
|
||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
Your {event} team"""))
|
Your {event} team"""))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ from pretix.api.serializers.order import (
|
|||||||
from pretix.api.serializers.waitinglist import WaitingListSerializer
|
from pretix.api.serializers.waitinglist import WaitingListSerializer
|
||||||
from pretix.base.i18n import LazyLocaleException
|
from pretix.base.i18n import LazyLocaleException
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPosition,
|
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
|
||||||
QuestionAnswer,
|
OrderPosition, OrderRefund, QuestionAnswer,
|
||||||
)
|
)
|
||||||
from pretix.base.services.invoices import invoice_pdf_task
|
from pretix.base.services.invoices import invoice_pdf_task
|
||||||
from pretix.base.signals import register_data_shredders
|
from pretix.base.signals import register_data_shredders
|
||||||
@@ -331,10 +331,14 @@ class PaymentInfoShredder(BaseDataShredder):
|
|||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def shred_data(self):
|
def shred_data(self):
|
||||||
provs = self.event.get_payment_providers()
|
provs = self.event.get_payment_providers()
|
||||||
for o in self.event.orders.all():
|
for obj in OrderPayment.objects.filter(order__event=self.event):
|
||||||
pprov = provs.get(o.payment_provider)
|
pprov = provs.get(obj.provider)
|
||||||
if pprov:
|
if pprov:
|
||||||
pprov.shred_payment_info(o)
|
pprov.shred_payment_info(obj)
|
||||||
|
for obj in OrderRefund.objects.filter(order__event=self.event):
|
||||||
|
pprov = provs.get(obj.provider)
|
||||||
|
if pprov:
|
||||||
|
pprov.shred_payment_info(obj)
|
||||||
|
|
||||||
|
|
||||||
@receiver(register_data_shredders, dispatch_uid="shredders_builtin")
|
@receiver(register_data_shredders, dispatch_uid="shredders_builtin")
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class EventPluginSignal(django.dispatch.Signal):
|
|||||||
searchpath, _ = searchpath.rsplit(".", 1)
|
searchpath, _ = searchpath.rsplit(".", 1)
|
||||||
|
|
||||||
# Only fire receivers from active plugins and core modules
|
# Only fire receivers from active plugins and core modules
|
||||||
if core_module or (sender and app and app.name in sender.get_plugins()):
|
excluded = settings.PRETIX_PLUGINS_EXCLUDE
|
||||||
|
if core_module or (sender and app and app.name in sender.get_plugins() and app.name not in excluded):
|
||||||
if not hasattr(app, 'compatibility_errors') or not app.compatibility_errors:
|
if not hasattr(app, 'compatibility_errors') or not app.compatibility_errors:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -124,6 +125,16 @@ subclass of pretix.base.payment.BasePaymentProvider or a list of these
|
|||||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
register_html_mail_renderers = EventPluginSignal(
|
||||||
|
providing_args=[]
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
This signal is sent out to get all known HTML email renderers. Receivers should return a
|
||||||
|
subclass of pretix.base.email.BaseHTMLMailRenderer or a list of these
|
||||||
|
|
||||||
|
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
|
"""
|
||||||
|
|
||||||
register_invoice_renderers = EventPluginSignal(
|
register_invoice_renderers = EventPluginSignal(
|
||||||
providing_args=[]
|
providing_args=[]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,4 +10,14 @@
|
|||||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||||
</p>
|
</p>
|
||||||
|
{% if request.user.is_staff and not staff_session %}
|
||||||
|
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||||
|
<p>
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||||
|
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -9,4 +9,14 @@
|
|||||||
<p class="links">
|
<p class="links">
|
||||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||||
</p>
|
</p>
|
||||||
|
{% if request.user.is_staff and not staff_session %}
|
||||||
|
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||||
|
<p>
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||||
|
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% load compress %}
|
{% load compress %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load staticfiles %}
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% load compress %}
|
{% load compress %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load staticfiles %}
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
@@ -5,163 +5,206 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=false">
|
<meta name="viewport" content="width=device-width, user-scalable=false">
|
||||||
</head>
|
<style type="text/css">
|
||||||
<style type="text/css">
|
body {
|
||||||
body {
|
background-color: #eee;
|
||||||
background-color: #e8e8e8;
|
background-position: top;
|
||||||
background-position: top;
|
background-repeat: repeat-x;
|
||||||
background-repeat: repeat-x;
|
font-family: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
font-family: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
font-size: 14px;
|
||||||
font-size: 14px;
|
line-height: 20px;
|
||||||
line-height: 20px;
|
color: #333;
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
margin-top: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 a, .content h2 a, .content h3 a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content h2, .content h3 {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: {{ color }};
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover, a:focus {
|
|
||||||
color: {{ color }};
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover, a:active {
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
|
|
||||||
/* These are technically the same, but use both */
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
word-wrap: break-word;
|
|
||||||
|
|
||||||
-ms-word-break: break-all;
|
|
||||||
/* This is the dangerous one in WebKit, as it breaks things wherever */
|
|
||||||
word-break: break-all;
|
|
||||||
/* Instead use this non-standard one: */
|
|
||||||
word-break: break-word;
|
|
||||||
|
|
||||||
/* Adds a hyphen where the word breaks, if supported (No Blink) */
|
|
||||||
-ms-hyphens: auto;
|
|
||||||
-moz-hyphens: auto;
|
|
||||||
-webkit-hyphens: auto;
|
|
||||||
hyphens: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
padding: 10px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
padding: 8px 18px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: {{ color }};
|
|
||||||
color: #FFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.layout {
|
|
||||||
width: 90%;
|
|
||||||
max-width: 900px;
|
|
||||||
border-spacing: 0px;
|
|
||||||
border-collapse: separate;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content table {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content table td {
|
|
||||||
vertical-align: top;
|
|
||||||
text-align: left;
|
|
||||||
padding: 5px 0;
|
|
||||||
}
|
|
||||||
a.button {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.33333;
|
|
||||||
border: 1px solid #cccccc;
|
|
||||||
border-radius: 6px;
|
|
||||||
-webkit-border-radius: 6px;
|
|
||||||
-moz-border-radius: 6px;
|
|
||||||
margin: 5px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: {{ color }};
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.header h1 {
|
|
||||||
font-size: 19px;
|
|
||||||
line-height: 24px;
|
|
||||||
margin: 0 9px 3px 0;
|
|
||||||
border-radius: 5px 5px;
|
|
||||||
-webkit-border-radius: 5px 5px;
|
|
||||||
-moz-border-radius: 5px 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 a {
|
|
||||||
padding: 3px 9px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 12px 0 8px;
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
td.containertd {
|
table.layout > tr > td {
|
||||||
background-color: #FFFFFF;
|
background-color: white;
|
||||||
border: 1px solid #cccccc;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
{% block addcss %}{% endblock %}
|
table.layout > tr > td.header {
|
||||||
</style>
|
padding: 0 20px;
|
||||||
<body>
|
text-align: center;
|
||||||
<table class="layout">
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 a, .header h1 a, .content h2 a, .content h3 a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2, .content h3 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: {{ color }};
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover, a:focus {
|
||||||
|
color: {{ color }};
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover, a:active {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
|
||||||
|
/* These are technically the same, but use both */
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
|
||||||
|
-ms-word-break: break-all;
|
||||||
|
/* This is the dangerous one in WebKit, as it breaks things wherever */
|
||||||
|
word-break: break-all;
|
||||||
|
/* Instead use this non-standard one: */
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
/* Adds a hyphen where the word breaks, if supported (No Blink) */
|
||||||
|
-ms-hyphens: auto;
|
||||||
|
-moz-hyphens: auto;
|
||||||
|
-webkit-hyphens: auto;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: {{ color }};
|
||||||
|
color: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.layout {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
border-spacing: 0px;
|
||||||
|
border-collapse: separate;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.wide {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content table td {
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.33333;
|
||||||
|
border: 1px solid #cccccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
-webkit-border-radius: 6px;
|
||||||
|
-moz-border-radius: 6px;
|
||||||
|
margin: 5px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: {{ color }};
|
||||||
|
}
|
||||||
|
|
||||||
|
{% block addcss %}{% endblock %}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type="text/css">
|
||||||
|
body, table, td {
|
||||||
|
font-family: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body align="center">
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<table width="100%"><tr><td align="center">
|
||||||
|
<table width="600"><tr><td align="center"
|
||||||
|
<![endif]-->
|
||||||
|
<table class="layout" width="600" border="0" cellspacing="0">
|
||||||
|
<!--[if !mso]><!-- -->
|
||||||
<tr>
|
<tr>
|
||||||
<td class="header" background="">
|
<td>
|
||||||
{% if event %}
|
<img class="wide" src="data:image/png;base64,
|
||||||
<h1><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
|
iVBORw0KGgoAAAANSUhEUgAAAlgAAAA8CAAAAACf95tlAAAAAXNCSVQI5gpbmQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAG/SURBVHja7dvRboMwDIXhvf/DLiQQAwkku9+qDgq2hPyfN6j1qTlx06/uMunbLMnnhL98fuzRDtYILEeZ7GBNwAIWsIB1LdkOVgaWo4gdLAGWo6x2sFZgOUq1g1WB5SjNDlYDlqcEK1dDB5anmK3eE7C4FnIpBNbVFLo7sB7d3huwKFlULGA9pWQJsJxls4G1ActbooWr2IHlLbMFrBlY7rJbwNqBxb2QZ8nAuiUGO9ICLOo71R1YN0X9td8KLJ8ZeDEDrAd+Za3A4mLIz4TAujGqv+tUYPmN4v8LcweW3zS1t++hActzCrtRYD3pMJQOLOeJ7NyBpZFdoWaFDVjuJ6BRswpTBZbCAn5hpsDq/fbHpDMTBZbC1TAzT2ApyMIVsDROQ2GWwFJo8PR2YP3eOtywzwrsGYD1J9vlHXzcmSKw7q/wU2OEwHpdtALHILA00jJfV8DSaVofvYOPlckB658sp/8VNrBkANahqnXqfhhXJgasgymHD8REZwfWmezzga+tQdhcAet0qry1FYV3osD6dP1QJL3YbYUkhfUCsK6einWRPI0pxjROWZbK+QcsAiwCLEKARYBFgEXIu/wAYbjtwujw8KwAAAAASUVORK5CYII="
|
||||||
{% else %}
|
style="max-height: 60px;">
|
||||||
<h1><a href="{{ site_url }}" target="_blank">{{ site }}</a></h1>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<tr>
|
||||||
|
<td class="header" align="center">
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<table cellpadding="20"><tr><td align="center">
|
||||||
|
<![endif]-->
|
||||||
|
{% if event %}
|
||||||
|
<h2><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a>
|
||||||
|
</h2>
|
||||||
|
{% else %}
|
||||||
|
<h2><a href="{{ site_url }}" target="_blank">{{ site }}</a></h2>
|
||||||
|
{% endif %}
|
||||||
|
{% block header %}
|
||||||
|
<h1>{{ subject }}</h1>
|
||||||
|
{% endblock %}
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
</td></tr></table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% include "pretixbase/email/separator.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
<!--[if !mso]><!-- -->
|
||||||
<tr>
|
<tr>
|
||||||
<td class="footer">
|
<td>
|
||||||
<div>
|
<br>
|
||||||
{% include "pretixbase/email/email_footer.html" %}
|
<img class="wide" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAlgAAAA8CAYAAAC6nMS5AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAOxAAADsQBlSsOGwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAPnSURBVHic7d3dbuJIEAbQsg2Ecd7/TQeDf3svVuFmdjJLxsGm+xwJKXcpqS76U3VTVCmlFAAArKbeugAAgNwIWAAAKxOwAABWJmABAKxMwAIAWNlh6wIAIiKWZYllWWKe5/vfEREppfsnIqKqqvsnIqKu66jrOpqmuf8NsDUBC3i6lFJM0xTjOMY0TbEsS6y1MaaqqqjrOg6HQxyPxzgcDvcwBvAslT1YwDN8BKpxHGOe56f+76Zp4ng83gMXwHcTsIBvsyxLDMMQfd/fr/y2Vtd1nE6nOJ1O0TTN1uUAmRKwgNV9hKppmrYu5VOHwyHO53Mcj8etSwEyI2ABqxmGIW6329OvAP9WXddxPp/j7e1t61KATAhYwF/r+z5ut9turgG/StAC1iJgAV82z3N0Xbf7q8BHNU0Tbdt6EA98mYAFPCylFNfrNfq+37qUb3U6naJtW2segIcJWMBDhmGIrutW21u1d1VVRdu2cTqdti4FeCECFvC/lDK1+h3TLOARAhbwR/M8x+VyeblvB66taZp4f3+3Pwv4IwEL+NQ4jnG5XIq5EvyTqqri/f3d7izgUwIW8FvDMMTlctm6jF1q29Y6B+C3BCzgP91ut7her1uXsWvn8zl+/PixdRnADglYwC+6riv2Mfuj3t7eom3brcsAdqbeugBgX4Srx/R9H13XbV0GsDMCFnB3u92Eqy/4+KkggA8CFhAR/z5o9+bq60reEQb8SsAC7qsY+Dtd18U4jluXAeyAgAWFW5ZFuFqRhaxAhIAFxfv586cloitKKVnMCghYULKu60xbvsE8z96zQeEELCjUOI4eZX+jvu+9x4KCCVhQoI9rLL6Xq0Iol4AFBbperw7+J0gpuSqEQglYUJh5nl0NPlHf9zFN09ZlAE8mYEFh/KzL85liQXkELCiIaco2pmmKYRi2LgN4IgELCuL38rZjigVlEbCgEMMwxLIsW5dRrGVZTLGgIAIWFML0ant6AOUQsKAA4zja2L4D8zxbPgqFELCgACYn+6EXUAYBCzK3LItvDu7INE3ewkEBBCzInIfV+6MnkD8BCzLnMN8fPYH8CViQsXmePW7fIX2B/AlYkDGTkv3SG8ibgAUZ87h9v/QG8iZgQaZSSg7xHZumKVJKW5cBfBMBCzIlXO2fHkG+BCzIlMN7//QI8iVgQaYc3vunR5AvAQsyZQ3A/tnoDvkSsCBDKSUPqF/Asiz6BJkSsCBDplevQ68gTwIWZMjV0+vQK8iTgAUZMhV5HXoFeRKwIEPe9bwOvYI8CViQIYf269AryJOABQCwMgELMmQq8jr0CvIkYEGGHNqvQ68gT/8AETAn3pyLgvsAAAAASUVORK5CYII="
|
||||||
</div>
|
style="max-height: 60px;">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<!--<![endif]-->
|
||||||
</table>
|
</table>
|
||||||
|
<div class="footer">
|
||||||
|
{% include "pretixbase/email/email_footer.html" %}
|
||||||
|
</div>
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
</td></tr></table>
|
||||||
|
</td></tr></table>
|
||||||
|
<![endif]-->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
{% extends "pretixbase/email/base.html" %}
|
{% extends "pretixbase/email/base.html" %}
|
||||||
{% load eventurl %}
|
{% load eventurl %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% block header %}
|
||||||
|
<h1>
|
||||||
|
{% if notification.url %}<a href="{{ notification.url }}">{% endif %}
|
||||||
|
{{ notification.title }}
|
||||||
|
{% if notification.url %}</a>{% endif %}
|
||||||
|
</h1>
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="containertd">
|
<td class="containertd">
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<table cellpadding="20"><tr><td>
|
||||||
|
<![endif]-->
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h3>
|
|
||||||
{% if notification.url %}<a href="{{ notification.url }}">{% endif %}
|
|
||||||
{{ notification.title }}
|
|
||||||
{% if notification.url %}</a>{% endif %}
|
|
||||||
</h3>
|
|
||||||
{% if notification.detail %}
|
{% if notification.detail %}
|
||||||
<p>{{ notification.detail }}</p>
|
<p>{{ notification.detail }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -35,10 +40,17 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
</td></tr></table>
|
||||||
|
<![endif]-->
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% include "pretixbase/email/separator.html" %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="containertd">
|
<td class="containertd">
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<table cellpadding="20"><tr><td>
|
||||||
|
<![endif]-->
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{% trans "You receive these emails based on your notification settings." %}<br>
|
{% trans "You receive these emails based on your notification settings." %}<br>
|
||||||
<a href="{{ settings_url }}">
|
<a href="{{ settings_url }}">
|
||||||
@@ -48,6 +60,9 @@
|
|||||||
{% trans "Click here disable all notifications immediately." %}
|
{% trans "Click here disable all notifications immediately." %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
</td></tr></table>
|
||||||
|
<![endif]-->
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,17 +4,24 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="containertd">
|
<td class="containertd">
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<table cellpadding="20"><tr><td>
|
||||||
|
<![endif]-->
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ body|safe }}
|
{{ body|safe }}
|
||||||
</div>
|
</div>
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
</td></tr></table>
|
||||||
|
<![endif]-->
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if order %}
|
{% if order %}
|
||||||
<tr>
|
{% include "pretixbase/email/separator.html" %}
|
||||||
<td class="gap"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="order containertd">
|
<td class="order containertd">
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<table cellpadding="20"><tr><td>
|
||||||
|
<![endif]-->
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{% trans "You are receiving this email because you placed an order for the following event:" %}<br>
|
{% trans "You are receiving this email because you placed an order for the following event:" %}<br>
|
||||||
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
|
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
|
||||||
@@ -24,18 +31,25 @@
|
|||||||
{% trans "View order details" %}
|
{% trans "View order details" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
</td></tr></table>
|
||||||
|
<![endif]-->
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if signature %}
|
{% if signature %}
|
||||||
<tr>
|
{% include "pretixbase/email/separator.html" %}
|
||||||
<td class="gap"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="order containertd">
|
<td class="order containertd">
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<table cellpadding="20"><tr><td>
|
||||||
|
<![endif]-->
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ signature | safe }}
|
{{ signature | safe }}
|
||||||
</div>
|
</div>
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
</td></tr></table>
|
||||||
|
<![endif]-->
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
15
src/pretix/base/templates/pretixbase/email/separator.html
Normal file
15
src/pretix/base/templates/pretixbase/email/separator.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!--[if !mso]><!-- -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img class="wide" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAlgAAAAoBAMAAADQ9ZkHAAAAA3NCSVQICAjb4U/gAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAC1QTFRF7u7u7+/v8PDw8fHx8vLy9PT09fX19/f3+Pj4+fn5+vr6/Pz8/f39/v7+////BLnnfgAAAKJJREFUaN7t1cGtgUEARtFfQkREDypQihpUoIQXpahADUqRiIgINdjehcXbSTjf8s5mchYzw9P+vQEBLFiwYMGChQAWLFiwYMFCAAsWLFhfipX9pe/S1+nH9EX6OX2Wfk2fpN/TR73QMgeH9E36Nn2fvko/pc/TL+nT9Fv6OP0xvB8sWLA+g+XZ9hvCggULFiwEsGDBggULFgJYsGDBgvXzewGTOlWA3NB0eQAAAABJRU5ErkJggg=="
|
||||||
|
style="max-height: 40px;">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<tr>
|
||||||
|
<td background="white" style="background-color: white;">
|
||||||
|
<hr background="white" style="background-color: white;"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<![endif]-->
|
||||||
@@ -25,3 +25,22 @@ def eventsignal(event: Event, signame: str, **kwargs):
|
|||||||
if response:
|
if response:
|
||||||
_html.append(response)
|
_html.append(response)
|
||||||
return mark_safe("".join(_html))
|
return mark_safe("".join(_html))
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def signal(signame: str, request, **kwargs):
|
||||||
|
"""
|
||||||
|
Send a signal and return the concatenated return values of all responses.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
{% signal request "path.to.signal" argument="value" ... %}
|
||||||
|
"""
|
||||||
|
sigstr = signame.rsplit('.', 1)
|
||||||
|
sigmod = importlib.import_module(sigstr[0])
|
||||||
|
signal = getattr(sigmod, sigstr[1])
|
||||||
|
_html = []
|
||||||
|
for receiver, response in signal.send(request, **kwargs):
|
||||||
|
if response:
|
||||||
|
_html.append(response)
|
||||||
|
return mark_safe("".join(_html))
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ ALLOWED_PROTOCOLS = ['http', 'https', 'mailto', 'tel']
|
|||||||
|
|
||||||
def safelink_callback(attrs, new=False):
|
def safelink_callback(attrs, new=False):
|
||||||
url = attrs.get((None, 'href'), '/')
|
url = attrs.get((None, 'href'), '/')
|
||||||
if not is_safe_url(url) and not url.startswith('mailto:') and not url.startswith('tel:'):
|
if not is_safe_url(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
|
||||||
signer = signing.Signer(salt='safe-redirect')
|
signer = signing.Signer(salt='safe-redirect')
|
||||||
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
||||||
attrs[None, 'target'] = '_blank'
|
attrs[None, 'target'] = '_blank'
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ def url_replace(request, *pairs):
|
|||||||
if key in dict_:
|
if key in dict_:
|
||||||
del dict_[key]
|
del dict_[key]
|
||||||
else:
|
else:
|
||||||
dict_[key] = p
|
dict_[key] = str(p)
|
||||||
key = None
|
key = None
|
||||||
return dict_.urlencode(safe='[]')
|
return dict_.urlencode(safe='[]')
|
||||||
|
|||||||
@@ -156,3 +156,10 @@ class BaseTicketOutput:
|
|||||||
The text on the download button in the frontend.
|
The text on the download button in the frontend.
|
||||||
"""
|
"""
|
||||||
return _('Download ticket')
|
return _('Download ticket')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def download_button_icon(self) -> str:
|
||||||
|
"""
|
||||||
|
The Font Awesome icon on the download button in the frontend.
|
||||||
|
"""
|
||||||
|
return 'fa-download'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user