forked from CGM_Public/pretix_original
Compare commits
140 Commits
v1.10.0
...
release/1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be96f86fe5 | ||
|
|
80e9f6a235 | ||
|
|
38fc826053 | ||
|
|
300578a44b | ||
|
|
dc2bcdcfbc | ||
|
|
7e18e89012 | ||
|
|
24f47722c0 | ||
|
|
04b679a4a7 | ||
|
|
f6713008aa | ||
|
|
15dc62855b | ||
|
|
4ed3df2b08 | ||
|
|
8a3eaae29c | ||
|
|
22edc016dd | ||
|
|
5205daae6d | ||
|
|
7ea79ebe56 | ||
|
|
3bfa8bd81e | ||
|
|
39abf63698 | ||
|
|
f68a6d1119 | ||
|
|
1a1a02d080 | ||
|
|
dacffc5f90 | ||
|
|
f2068b2663 | ||
|
|
989282ffbe | ||
|
|
e469b2e6ad | ||
|
|
8eaada992f | ||
|
|
f5dba45fa0 | ||
|
|
e72b5893c4 | ||
|
|
e78a176e9f | ||
|
|
8143999803 | ||
|
|
219c2c94e8 | ||
|
|
37f612801f | ||
|
|
0b12b7aa89 | ||
|
|
14da25bd9e | ||
|
|
3a713541a2 | ||
|
|
c7a547a875 | ||
|
|
e12caf186c | ||
|
|
1ee6e31538 | ||
|
|
083c94403b | ||
|
|
67121decbf | ||
|
|
fcd6bb1084 | ||
|
|
a81a4b895a | ||
|
|
c50c5177b8 | ||
|
|
30eefe57ef | ||
|
|
ce33cce5a9 | ||
|
|
d0dfde382c | ||
|
|
7fb2d0526e | ||
|
|
fb34467cba | ||
|
|
7e62cddb97 | ||
|
|
78b31149b5 | ||
|
|
817038563f | ||
|
|
56ca2305bd | ||
|
|
fc7bafe3d9 | ||
|
|
d622f38e1d | ||
|
|
139810c8a5 | ||
|
|
f8cc332ed7 | ||
|
|
db24bd4d78 | ||
|
|
d056013296 | ||
|
|
7e647f7085 | ||
|
|
322068b5e0 | ||
|
|
96247d5fa0 | ||
|
|
6b7338aff0 | ||
|
|
59d85cc218 | ||
|
|
7f90fdedf1 | ||
|
|
7723c956bc | ||
|
|
d0c10a8f72 | ||
|
|
c56dd52bd6 | ||
|
|
a7374f5bbd | ||
|
|
251d62f3c4 | ||
|
|
b8c041d0d6 | ||
|
|
dd42037f21 | ||
|
|
50575d45c1 | ||
|
|
7268c7fb70 | ||
|
|
83572960d5 | ||
|
|
39f22fa314 | ||
|
|
69ab5d8c2e | ||
|
|
58111465bc | ||
|
|
697e56962a | ||
|
|
7d6c45f689 | ||
|
|
faa34c9230 | ||
|
|
8a64c1815a | ||
|
|
f65f166ea6 | ||
|
|
f054e700a2 | ||
|
|
ff8b9e4e1a | ||
|
|
ed47e94a70 | ||
|
|
93090f3481 | ||
|
|
fa211574a2 | ||
|
|
9a18eda404 | ||
|
|
fd3e941472 | ||
|
|
ad03980c2b | ||
|
|
a6af7a90cb | ||
|
|
ba170632a5 | ||
|
|
f8c536afd3 | ||
|
|
53b75e1ca2 | ||
|
|
a632b08664 | ||
|
|
954b7f6d9e | ||
|
|
8c0fb90420 | ||
|
|
5d697a3189 | ||
|
|
2c04a4daec | ||
|
|
f3c1296105 | ||
|
|
50c3f025e2 | ||
|
|
9b4a3bcbef | ||
|
|
d8486e8f90 | ||
|
|
fc731c3f58 | ||
|
|
a62fbd54d4 | ||
|
|
f81a7a397a | ||
|
|
8543cb7ece | ||
|
|
5015c9d8f3 | ||
|
|
2b4dede658 | ||
|
|
d2b15ae679 | ||
|
|
5ece8fd2f7 | ||
|
|
128203800c | ||
|
|
f0a1397eea | ||
|
|
ef42055de1 | ||
|
|
15aff6030c | ||
|
|
215a28fac5 | ||
|
|
e9e155201f | ||
|
|
21a457cadd | ||
|
|
8e7eab636f | ||
|
|
d7ce46c14a | ||
|
|
ee7f8940d0 | ||
|
|
cf1883a039 | ||
|
|
7ec614a691 | ||
|
|
e4fc49769f | ||
|
|
d97df9cddd | ||
|
|
33a254ce92 | ||
|
|
3ec0366c21 | ||
|
|
759db2d20e | ||
|
|
8f7c6521a9 | ||
|
|
725a7f21c4 | ||
|
|
e24611fde9 | ||
|
|
7a077095ee | ||
|
|
fade09ca8f | ||
|
|
9ab39904e8 | ||
|
|
fb5fa57fd6 | ||
|
|
9e84e78215 | ||
|
|
f5bf2ac4ca | ||
|
|
c50a8e7740 | ||
|
|
3ece911018 | ||
|
|
1011b67f0a | ||
|
|
497679284a | ||
|
|
263df3ac4d |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -6,6 +6,7 @@ src/static/datetimepicker/* linguist-vendored
|
||||
src/static/colorpicker/* linguist-vendored
|
||||
src/static/fileupload/* linguist-vendored
|
||||
src/static/vuejs/* linguist-vendored
|
||||
src/static/select2/* linguist-vendored
|
||||
src/static/charts/* linguist-vendored
|
||||
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/fabric.* linguist-vendored
|
||||
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/pdf.* linguist-vendored
|
||||
|
||||
14
.travis.sh
14
.travis.sh
@@ -25,19 +25,27 @@ if [ "$1" == "doctests" ]; then
|
||||
cd doc
|
||||
make doctest
|
||||
fi
|
||||
if [ "$1" == "spelling" ]; then
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt
|
||||
cd doc
|
||||
make spelling
|
||||
if [ -s _build/spelling/output.txt ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ "$1" == "tests" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
py.test --rerun 5 tests
|
||||
py.test --reruns 5 tests
|
||||
fi
|
||||
if [ "$1" == "tests-cov" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
coverage run -m py.test --rerun 5 tests && codecov
|
||||
coverage run -m py.test --reruns 5 tests && codecov
|
||||
fi
|
||||
if [ "$1" == "plugins" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
@@ -50,7 +58,7 @@ if [ "$1" == "plugins" ]; then
|
||||
cd pretix-cartshare
|
||||
python setup.py develop
|
||||
make
|
||||
py.test --rerun 5 tests
|
||||
py.test --reruns 5 tests
|
||||
popd
|
||||
|
||||
fi
|
||||
|
||||
@@ -36,5 +36,10 @@ matrix:
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.6
|
||||
env: JOB=plugins
|
||||
- python: 3.6
|
||||
env: JOB=spelling
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
apt:
|
||||
packages:
|
||||
- enchant
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
cd /pretix/src
|
||||
export DJANGO_SETTINGS_MODULE=production_settings
|
||||
export DATA_DIR=/data/
|
||||
export HOME=/pretix
|
||||
NUM_WORKERS=10
|
||||
|
||||
if [ ! -d /data/logs ]; then
|
||||
|
||||
@@ -175,3 +175,9 @@ pseudoxml:
|
||||
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
|
||||
@echo
|
||||
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
|
||||
|
||||
spelling:
|
||||
$(SPHINXBUILD) -b spelling $(ALLSPHINXOPTS) $(BUILDDIR)/spelling
|
||||
@echo
|
||||
@echo "Spelling check finished, look at the results in " \
|
||||
"$(BUILDDIR)/spelling/output.txt."
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
.. _`config`:
|
||||
|
||||
.. spelling:: Galera
|
||||
|
||||
Configuration file
|
||||
==================
|
||||
|
||||
@@ -45,7 +47,7 @@ Example::
|
||||
|
||||
``datadir``
|
||||
The local path to a data directory that will be used for storing user uploads and similar
|
||||
data. Defaults to thea value of the environment variable ``DATA_DIR`` or ``data``.
|
||||
data. Defaults to the value of the environment variable ``DATA_DIR`` or ``data``.
|
||||
|
||||
``plugins_default``
|
||||
A comma-separated list of plugins that are enabled by default for all new events.
|
||||
|
||||
@@ -162,7 +162,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
|
||||
-v /etc/pretix:/etc/pretix \
|
||||
-v /var/run/redis:/var/run/redis \
|
||||
-v /var/run/mysqld:/var/run/mysqld \
|
||||
pretix/standalone all
|
||||
pretix/standalone:stable all
|
||||
ExecStop=/usr/bin/docker stop %n
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.. highlight:: ini
|
||||
|
||||
.. spelling:: SQL
|
||||
|
||||
General remarks
|
||||
===============
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ checkin_count integer Number of check
|
||||
|
||||
This resource has been added.
|
||||
|
||||
.. versionchanged:: 1.11
|
||||
|
||||
The ``positions`` endpoints have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -165,7 +169,7 @@ Endpoints
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(id)/
|
||||
|
||||
Update a check-in list. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be resetted to default. With ``PATCH``, you only need to provide the fields that you
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``id`` field and the ``checkin_count`` and ``position_count``
|
||||
@@ -213,7 +217,7 @@ Endpoints
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/checkinlist/(id)/
|
||||
|
||||
Delete a check-in list. Note that this also deletes the information on all checkins performed via this list.
|
||||
Delete a check-in list. Note that this also deletes the information on all check-ins performed via this list.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -236,3 +240,163 @@ Endpoints
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
|
||||
|
||||
Order position endpoints
|
||||
------------------------
|
||||
|
||||
.. 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
|
||||
the :ref:`order-position-resource`, with one important difference: the ``checkins`` value will only include
|
||||
check-ins for the selected list.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 23442,
|
||||
"order": "ABC12",
|
||||
"positionid": 1,
|
||||
"item": 1345,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": null,
|
||||
"tax_value": "0.00",
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"checkins": [
|
||||
{
|
||||
"list": 1,
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
}
|
||||
],
|
||||
"answers": [
|
||||
{
|
||||
"question": 12,
|
||||
"answer": "Foo",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"downloads": [
|
||||
{
|
||||
"output": "pdf",
|
||||
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``order__code``,
|
||||
``order__datetime``, ``positionid``, ``attendee_name``, ``last_checked_in`` and ``order__email``. Default:
|
||||
``attendee_name,positionid``
|
||||
:query string order: Only return positions of the order with the given order code
|
||||
:query integer item: Only return positions with the purchased item matching the given ID.
|
||||
:query integer variation: Only return positions with the purchased item variation matching the given ID.
|
||||
: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.
|
||||
:query string secret: Only return positions with the given ticket secret.
|
||||
:query bollean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been
|
||||
checked in already on this list.
|
||||
:query integer subevent: Only return positions of the sub-event with the given ID
|
||||
:query integer addon_to: Only return positions that are add-ons to the position with the given ID.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param list: The ID of the check-in list to look for
|
||||
: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 check-in list does not exist.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)
|
||||
|
||||
Returns information on one order position, identified by its internal ID.
|
||||
The result format is the same as the :ref:`order-position-resource`, with one important difference: the
|
||||
``checkins`` value will only include check-ins for the selected list.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/ 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": 23442,
|
||||
"order": "ABC12",
|
||||
"positionid": 1,
|
||||
"item": 1345,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": null,
|
||||
"tax_value": "0.00",
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"checkins": [
|
||||
{
|
||||
"list": 1,
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
}
|
||||
],
|
||||
"answers": [
|
||||
{
|
||||
"question": 12,
|
||||
"answer": "Foo",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"downloads": [
|
||||
{
|
||||
"output": "pdf",
|
||||
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer 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 id: The ``id`` field of the order 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 order position or check-in list does not exist.
|
||||
|
||||
@@ -10,6 +10,8 @@ Resources and endpoints
|
||||
taxrules
|
||||
categories
|
||||
items
|
||||
item_variations
|
||||
item_add-ons
|
||||
questions
|
||||
quotas
|
||||
orders
|
||||
|
||||
@@ -221,5 +221,5 @@ Endpoints
|
||||
: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 409: The file is not yet ready and will now be prepared. Retry the request after waiting vor a few
|
||||
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||
seconds.
|
||||
|
||||
246
doc/api/resources/item_add-ons.rst
Normal file
246
doc/api/resources/item_add-ons.rst
Normal file
@@ -0,0 +1,246 @@
|
||||
Item add-ons
|
||||
============
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
With add-ons, you can specify products that can be bought as an addition to this specific product. For example, if you
|
||||
host a conference with a base conference ticket and a number of workshops, you could define the workshops as add-ons to
|
||||
the conference ticket. With this configuration, the workshops cannot be bought on their own but only in combination with
|
||||
a conference ticket. You can here specify categories of products that can be used as add-ons to this product. You can
|
||||
also specify the minimum and maximum number of add-ons of the given category that can or need to be chosen. The user can
|
||||
buy every add-on from the category at most once. If an add-on product has multiple variations, only one of them can be
|
||||
bought.
|
||||
The add-ons resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the add-on
|
||||
addon_category integer Internal ID of the item category the add-on can be
|
||||
chosen from.
|
||||
min_count integer The minimal number of add-ons that need to be chosen.
|
||||
max_count integer The maximal number of add-ons that can be chosen.
|
||||
position integer An integer, used for sorting
|
||||
price_included boolean Adding this add-on to the item is free
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
This resource has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/
|
||||
|
||||
Returns a list of all add-ons for a given item.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/items/11/addons/ 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": 2,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 3,
|
||||
"addon_category": 1,
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 0,
|
||||
"price_included": true
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"addon_category": 2,
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"price_included": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
: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 item: The ``id`` field of the item to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/(id)/
|
||||
|
||||
Returns information on one add-on, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/items/1/addons/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": 3,
|
||||
"addon_category": 1,
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"price_included": true
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param item: The ``id`` field of the item to fetch
|
||||
:param id: The ``id`` field of the add-on 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.
|
||||
|
||||
.. http:post:: /api/v1/organizers/bigevents/events/sampleconf/items/1/addons/
|
||||
|
||||
Creates a new add-on
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"addon_category": 1,
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"price_included": true
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"addon_category": 1,
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"price_included": true
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event/item to create a add-on for
|
||||
:param event: The ``slug`` field of the event to create a add-on for
|
||||
:param item: The ``id`` field of the item to create a add-on for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The add-on could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/addon/(id)/
|
||||
|
||||
Update an add-on. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``id`` field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/addons/3/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"addon_category": 1,
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"price_included": true
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param item: The ``id`` field of the item to modify
|
||||
:param id: The ``id`` field of the add-on to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The add-on could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/addons/(id)/
|
||||
|
||||
Delete an add-on.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/addons/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to modify
|
||||
:param id: The ``id`` field of the add-on to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
258
doc/api/resources/item_variations.rst
Normal file
258
doc/api/resources/item_variations.rst
Normal file
@@ -0,0 +1,258 @@
|
||||
Item variations
|
||||
===============
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
Variations of items can be use for products (items) that are available in different sizes, colors or other variations
|
||||
of the same product.
|
||||
The addons resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the variation
|
||||
default_price money (string) The price set directly for this variation or ``null``
|
||||
price money (string) The price used for this variation. This is either the
|
||||
same as ``default_price`` if that value is set or equal
|
||||
to the item's ``default_price`` (read-only).
|
||||
active boolean If ``False``, this variation will not be sold or shown.
|
||||
description multi-lingual string A public description of the variation. May contain
|
||||
Markdown syntax or can be ``null``.
|
||||
position integer An integer, used for sorting
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
This resource has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/variations/
|
||||
|
||||
Returns a list of all variations for a given item.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/items/11/variations/ 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": 2,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"value": {
|
||||
"en": "S"
|
||||
},
|
||||
"active": true,
|
||||
"description": {
|
||||
"en": "Test2"
|
||||
},
|
||||
"position": 0,
|
||||
"default_price": "223.00",
|
||||
"price": 223.0
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"value": {
|
||||
"en": "L"
|
||||
},
|
||||
"active": true,
|
||||
"description": {},
|
||||
"position": 1,
|
||||
"default_price": null,
|
||||
"price": 15.0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query boolean active: If set to ``true`` or ``false``, only items with this value for the field ``active`` will be
|
||||
returned.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param item: The ``id`` field of the item to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/variations/(id)/
|
||||
|
||||
Returns information on one variation, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/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": 3,
|
||||
"value": {
|
||||
"en": "Student"
|
||||
},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param item: The ``id`` field of the item to fetch
|
||||
:param id: The ``id`` field of the variation 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.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/variations/
|
||||
|
||||
Creates a new variation
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event/item to create a variation for
|
||||
:param event: The ``slug`` field of the event to create a variation for
|
||||
:param item: The ``id`` field of the item to create a variation for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The variation could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/variations/(id)/
|
||||
|
||||
Update a variation. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``id`` and the ``price`` field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"active": false,
|
||||
"position": 1
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"active": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to modify
|
||||
:param id: The ``id`` field of the variation to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The variation could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/variations/(id)/
|
||||
|
||||
Delete a variation.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to modify
|
||||
:param id: The ``id`` field of the variation to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
@@ -33,6 +33,7 @@ admission boolean ``True`` for it
|
||||
(such as add-ons or merchandise).
|
||||
position integer An integer, used for sorting
|
||||
picture string A product picture to be displayed in the shop
|
||||
(read-only).
|
||||
available_from datetime The first date time at which this item can be bought
|
||||
(or ``null``).
|
||||
available_until datetime The last date time at which this item can be bought
|
||||
@@ -53,10 +54,9 @@ max_per_order integer This product ca
|
||||
checkin_attention boolean If ``True``, the check-in app should show a warning
|
||||
that this ticket requires special attention if such
|
||||
a product is being scanned.
|
||||
has_variations boolean Shows whether or not this item has variations
|
||||
(read-only).
|
||||
has_variations boolean Shows whether or not this item has variations.
|
||||
variations list of objects A list with one object for each variation of this item.
|
||||
Can be empty.
|
||||
Can be empty. Only writable on POST.
|
||||
├ id integer Internal ID of the variation
|
||||
├ default_price money (string) The price set directly for this variation or ``null``
|
||||
├ price money (string) The price used for this variation. This is either the
|
||||
@@ -66,12 +66,14 @@ variations list of objects A list with one
|
||||
├ description multi-lingual string A public description of the variation. May contain
|
||||
Markdown syntax or can be ``null``.
|
||||
└ position integer An integer, used for sorting
|
||||
addons list of objects Definition of add-ons that can be chosen for this item
|
||||
addons list of objects Definition of add-ons that can be chosen for this item.
|
||||
Only writable on POST.
|
||||
├ addon_category integer Internal ID of the item category the add-on can be
|
||||
chosen from.
|
||||
├ min_count integer The minimal number of add-ons that need to be chosen.
|
||||
├ max_count integer The maxima number of add-ons that can be chosen.
|
||||
├ max_count integer The maximal number of add-ons that can be chosen.
|
||||
└ position integer An integer, used for sorting
|
||||
└ price_included boolean Adding this add-on to the item is free
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
@@ -79,6 +81,20 @@ addons list of objects Definition of a
|
||||
The attribute ``tax_rule`` has been added. ``tax_rate`` is kept for compatibility. The attribute
|
||||
``checkin_attention`` has been added.
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
|
||||
The attribute ``price_included`` has been added to ``addons``.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Please note that an item either always has variations or never has. Once created with variations the item can never
|
||||
change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least
|
||||
one variation.
|
||||
|
||||
Also note that ``variations`` and ``addons`` are only supported on ``POST``. To update/delete variations and add-ons please
|
||||
use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT`` with nested
|
||||
``variations`` and/or ``addons``.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -239,3 +255,226 @@ Endpoints
|
||||
: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:post:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/
|
||||
|
||||
Creates a new item
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/items/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Standard ticket"},
|
||||
"default_price": "23.00",
|
||||
"category": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"free_price": false,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"value": {"en": "Regular"},
|
||||
"default_price": null,
|
||||
"price": "23.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"addons": []
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Standard ticket"},
|
||||
"default_price": "23.00",
|
||||
"category": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"free_price": false,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": true,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"value": {"en": "Regular"},
|
||||
"default_price": null,
|
||||
"price": "23.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"addons": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create an item for
|
||||
:param event: The ``slug`` field of the event to create an item for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The item could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/
|
||||
|
||||
Update an item. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``has_variations``, ``variations`` and the ``addon`` field. If
|
||||
you need to update/delete variations or add-ons please use the nested dedicated endpoints.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"name": {"en": "Ticket"},
|
||||
"default_price": "25.00"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Ticket"},
|
||||
"default_price": "25.00",
|
||||
"category": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"free_price": false,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": true,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"value": {"en": "Regular"},
|
||||
"default_price": null,
|
||||
"price": "23.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"addons": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The item could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/
|
||||
|
||||
Delete an item.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.. spelling:: checkins
|
||||
|
||||
Orders
|
||||
======
|
||||
|
||||
@@ -79,13 +81,15 @@ downloads list of objects List of ticket
|
||||
|
||||
The attributes ``invoice_address.vat_id_validated`` and ``invoice_address.is_business`` have been added.
|
||||
The attributes ``order.payment_fee``, ``order.payment_fee_tax_rate`` and ``order.payment_fee_tax_value`` have been
|
||||
deprecated in favour of the new ``fees`` attribute but will still be served and removed in 1.9.
|
||||
deprecated in favor of the new ``fees`` attribute but will still be served and removed in 1.9.
|
||||
|
||||
.. versionchanged:: 1.9
|
||||
|
||||
First write operations (``…/mark_paid/``, ``…/mark_pending/``, ``…/mark_canceled/``, ``…/mark_expired/``) have been added.
|
||||
The attribute ``invoice_address.internal_reference`` has been added.
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
Order position resource
|
||||
-----------------------
|
||||
|
||||
@@ -110,6 +114,7 @@ secret string Secret code pri
|
||||
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``).
|
||||
checkins list of objects List of check-ins with this ticket
|
||||
├ list integer Internal ID of the check-in list
|
||||
└ datetime datetime Time of check-in
|
||||
downloads list of objects List of ticket download options
|
||||
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
|
||||
@@ -124,6 +129,10 @@ answers list of objects Answers to user
|
||||
|
||||
The attribute ``tax_rule`` has been added.
|
||||
|
||||
.. versionchanged:: 1.11
|
||||
|
||||
The attribute ``checkins.list`` has been added.
|
||||
|
||||
|
||||
Order endpoints
|
||||
---------------
|
||||
@@ -198,6 +207,7 @@ Order endpoints
|
||||
"subevent": null,
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
}
|
||||
],
|
||||
@@ -304,6 +314,7 @@ Order endpoints
|
||||
"subevent": null,
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
}
|
||||
],
|
||||
@@ -342,7 +353,7 @@ Order endpoints
|
||||
|
||||
Download tickets for an order, identified by its order code. Depending on the chosen output, the response might
|
||||
be a ZIP file, PDF file or something else. The order details response contains a list of output options for this
|
||||
partictular order.
|
||||
particular order.
|
||||
|
||||
Tickets can be only downloaded if the order is paid and if ticket downloads are active. Note that in some cases the
|
||||
ticket file might not yet have been created. In that case, you will receive a status code :http:statuscode:`409` and
|
||||
@@ -622,6 +633,7 @@ Order position endpoints
|
||||
"subevent": null,
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
}
|
||||
],
|
||||
@@ -701,6 +713,7 @@ Order position endpoints
|
||||
"subevent": null,
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.. spelling:: checkin
|
||||
|
||||
Questions
|
||||
=========
|
||||
|
||||
@@ -23,15 +25,25 @@ type string The expected ty
|
||||
* ``C`` – choice from a list
|
||||
* ``M`` – multiple choice from a list
|
||||
* ``F`` – file upload
|
||||
* ``D`` – date
|
||||
* ``H`` – time
|
||||
* ``W`` – date and time
|
||||
required boolean If ``True``, the question needs to be filled out.
|
||||
position integer An integer, used for sorting
|
||||
items list of integers List of item IDs this question is assigned to.
|
||||
ask_during_checkin boolean If ``True``, this question will not be asked while
|
||||
buying the ticket, but will show up when redeeming
|
||||
the ticket instead.
|
||||
options list of objects In case of question type ``C`` or ``M``, this lists the
|
||||
available objects.
|
||||
├ id integer Internal ID of the option
|
||||
└ answer multi-lingual string The displayed value of this option
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
The values ``D``, ``H``, and ``W`` for the field ``type`` are now allowed and the ``ask_during_checkin`` field has
|
||||
been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -68,6 +80,7 @@ Endpoints
|
||||
"required": false,
|
||||
"items": [1, 2],
|
||||
"position": 1,
|
||||
"ask_during_checkin": false,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -121,6 +134,7 @@ Endpoints
|
||||
"type": "C",
|
||||
"required": false,
|
||||
"items": [1, 2],
|
||||
"ask_during_checkin": false,
|
||||
"position": 1,
|
||||
"options": [
|
||||
{
|
||||
|
||||
@@ -158,7 +158,7 @@ Endpoints
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/quotas/(id)/
|
||||
|
||||
Update a quota. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be resetted to default. With ``PATCH``, you only need to provide the fields that you
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``id`` field.
|
||||
|
||||
11
doc/checkin_filter.py
Normal file
11
doc/checkin_filter.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from enchant.tokenize import get_tokenizer, Filter, unit_tokenize
|
||||
|
||||
class CheckinFilter(Filter):
|
||||
""" If a word looks like checkin_count, it refers to a so-called variable in
|
||||
the code, and is treated as being spelled right."""
|
||||
|
||||
def _split(self, word):
|
||||
if word[:8] == "checkin_":
|
||||
return unit_tokenize(word[8:])
|
||||
|
||||
return unit_tokenize(word)
|
||||
20
doc/conf.py
20
doc/conf.py
@@ -45,6 +45,7 @@ extensions = [
|
||||
'sphinx.ext.coverage',
|
||||
'sphinxcontrib.httpdomain',
|
||||
'sphinxcontrib.images',
|
||||
'sphinxcontrib.spelling',
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
@@ -290,3 +291,22 @@ texinfo_documents = [
|
||||
images_config = {
|
||||
'default_image_width': '250px'
|
||||
}
|
||||
|
||||
# -- Options for Spelling output ------------------------------------------
|
||||
|
||||
# String specifying the language, as understood by PyEnchant and enchant.
|
||||
# Defaults to en_US for US English.
|
||||
spelling_lang = 'en_US'
|
||||
|
||||
# String specifying a file containing a list of words known to be spelled
|
||||
# correctly but that do not appear in the language dictionary selected by
|
||||
# spelling_lang. The file should contain one word per line.
|
||||
spelling_word_list_filename='spelling_wordlist.txt'
|
||||
|
||||
# Boolean controlling whether suggestions for misspelled words are printed.
|
||||
# Defaults to False.
|
||||
spelling_show_suggestions=True
|
||||
|
||||
# List of filter classes to be added to the tokenizer that produces words to be checked.
|
||||
from checkin_filter import CheckinFilter
|
||||
spelling_filters=[CheckinFilter]
|
||||
|
||||
@@ -25,7 +25,7 @@ If you want to add a custom view to the control area of an event, just register
|
||||
views.admin_view, name='backend'),
|
||||
]
|
||||
|
||||
It is required that your URL paramaters are called ``organizer`` and ``event``. If you want to
|
||||
It is required that your URL parameters are called ``organizer`` and ``event``. If you want to
|
||||
install a view on organizer level, you can leave out the ``event``.
|
||||
|
||||
You can then implement the view as you would normally do. Our middleware will automatically
|
||||
|
||||
@@ -21,10 +21,10 @@ that we'll provide in this plugin::
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.signals import register_data_exporter
|
||||
from pretix.base.signals import register_data_exporters
|
||||
|
||||
|
||||
@receiver(register_data_exporter, dispatch_uid="exporter_myexporter")
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_myexporter")
|
||||
def register_data_exporter(sender, **kwargs):
|
||||
from .exporter import MyExporter
|
||||
return MyExporter
|
||||
|
||||
@@ -11,7 +11,7 @@ Core
|
||||
----
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Logging
|
||||
=======
|
||||
Logging and notifications
|
||||
=========================
|
||||
|
||||
As pretix is handling monetary transactions, we are very careful to make it possible to review all changes
|
||||
in the system that lead to the current state.
|
||||
@@ -19,7 +19,7 @@ To actually log an action, you can just call the ``log_action`` method on your o
|
||||
order.log_action('pretix.event.order.canceled', user=user, data={})
|
||||
|
||||
The positional ``action`` argument should represent the type of action and should be globally unique, we
|
||||
recomment do prefix it with your packagename, e.g. ``paypal.payment.rejected``. The ``user`` argument is
|
||||
recommend to prefix it with your package name, e.g. ``paypal.payment.rejected``. The ``user`` argument is
|
||||
optional and may contain the user who performed the action. The optional ``data`` argument can contain
|
||||
additional information about this action.
|
||||
|
||||
@@ -81,6 +81,61 @@ implementation could look like::
|
||||
if logentry.action_type in plains:
|
||||
return plains[logentry.action_type]
|
||||
|
||||
Sending notifications
|
||||
---------------------
|
||||
|
||||
If you think that the logged information might be important or urgent enough to send out a notification to interested
|
||||
organizers. In this case, you should listen for the :py:attr:`pretix.base.signals.register_notification_types` signal
|
||||
to register a notification type::
|
||||
|
||||
@receiver(register_notification_types)
|
||||
def register_my_notification_types(sender, **kwargs):
|
||||
return [MyNotificationType(sender)]
|
||||
|
||||
Note that this event is different than other events send out by pretix: ``sender`` may be an event or ``None``. The
|
||||
latter case is required to let the user define global notification preferences for all events.
|
||||
|
||||
You also need to implement a custom class that specifies how notifications should be handled for your notification type.
|
||||
You should subclass the base ``NotificationType`` class and implement all its members:
|
||||
|
||||
.. autoclass:: pretix.base.notifications.NotificationType
|
||||
:members: action_type, verbose_name, required_permission, build_notification
|
||||
|
||||
A simple implementation could look like this::
|
||||
|
||||
class MyNotificationType(NotificationType):
|
||||
required_permission = "can_view_orders"
|
||||
action_type = "pretix.event.order.paid"
|
||||
verbose_name = _("Order has been paid")
|
||||
|
||||
def build_notification(self, logentry: LogEntry):
|
||||
order = logentry.content_object
|
||||
|
||||
order_url = build_absolute_uri(
|
||||
'control:event.order',
|
||||
kwargs={
|
||||
'organizer': logentry.event.organizer.slug,
|
||||
'event': logentry.event.slug,
|
||||
'code': order.code
|
||||
}
|
||||
)
|
||||
|
||||
n = Notification(
|
||||
event=logentry.event,
|
||||
title=_('Order {code} has been marked as paid').format(code=order.code),
|
||||
url=order_url
|
||||
)
|
||||
n.add_attribute(_('Order code'), order.code)
|
||||
n.add_action(_('View order details'), order_url)
|
||||
return n
|
||||
|
||||
As you can see, the relevant code is in the ``build_notification`` method that is supposed to create a ``Notification``
|
||||
method that has a title, description, URL, attributes, and actions. The full definition of ``Notification`` is the
|
||||
following:
|
||||
|
||||
.. autoclass:: pretix.base.notifications.Notification
|
||||
:members: add_action, add_attribute
|
||||
|
||||
|
||||
Logging technical information
|
||||
-----------------------------
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
.. spelling:: answ contrib
|
||||
|
||||
Data model
|
||||
==========
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ Forms
|
||||
-----
|
||||
|
||||
Hierarkey also provides a base class for forms that allow the modification of settings. pretix contains a
|
||||
subclass that also adds suport for internationalized fields:
|
||||
subclass that also adds support for internationalized fields:
|
||||
|
||||
.. autoclass:: pretix.base.forms.SettingsForm
|
||||
|
||||
@@ -65,4 +65,4 @@ Plugins can add custom hardcoded defaults in the following way::
|
||||
Make sure that you include this code in a module that is imported at app loading time.
|
||||
|
||||
.. _django-hierarkey: https://github.com/raphaelm/django-hierarkey
|
||||
.. _documentation: https://django-hierarkey.readthedocs.io/en/latest/
|
||||
.. _documentation: https://django-hierarkey.readthedocs.io/en/latest/
|
||||
|
||||
@@ -67,7 +67,7 @@ available as ``plugins:sendmail:send``.
|
||||
Generating a URL for the frontend is a complicated task, because you need to know whether the event's
|
||||
organizer uses a custom URL or not and then generate the URL with a different domain and different
|
||||
arguments based on this information. pretix provides some helpers to make this easier. The first helper
|
||||
is a python method that emulates a behaviour similar to ``reverse``:
|
||||
is a python method that emulates a behavior similar to ``reverse``:
|
||||
|
||||
.. autofunction:: pretix.multidomain.urlreverse.eventreverse
|
||||
|
||||
@@ -82,5 +82,5 @@ Implementation details
|
||||
----------------------
|
||||
|
||||
There are some other caveats when using a design like this, e.g. you have to care about cookie domains
|
||||
and referer verification yourself. If you want to see how we built this, look into the ``pretix/multidomain/``
|
||||
and referrer verification yourself. If you want to see how we built this, look into the ``pretix/multidomain/``
|
||||
sub-tree.
|
||||
|
||||
@@ -86,7 +86,7 @@ and head to http://localhost:8000/
|
||||
|
||||
As we did not implement an overall front page yet, you need to go directly to
|
||||
http://localhost:8000/control/ for the admin view or, if you imported the test
|
||||
data as suggested above, to the event page at http://localhost:8000/bigevents/2018/
|
||||
data as suggested above, to the event page at http://localhost:8000/bigevents/2019/
|
||||
|
||||
.. note:: If you want the development server to listen on a different interface or
|
||||
port (for example because you develop on `pretixdroid`_), you can check
|
||||
@@ -106,7 +106,7 @@ Execute the following commands to check for code style errors::
|
||||
isort -c -rc .
|
||||
python manage.py check
|
||||
|
||||
Execute the following command to run pretix' test suite (might take a coumple of minutes)::
|
||||
Execute the following command to run pretix' test suite (might take a couple of minutes)::
|
||||
|
||||
py.test
|
||||
|
||||
@@ -122,7 +122,7 @@ for example::
|
||||
flake8 . || exit 1
|
||||
isort -q -rc -c . || exit 1
|
||||
|
||||
This keeps you from accidentally creating commits violating the sdtyle guide.
|
||||
This keeps you from accidentally creating commits violating the style guide.
|
||||
|
||||
Working with mails
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
.. spelling::
|
||||
Analytics
|
||||
|
||||
List of plugins
|
||||
===============
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ uses to communicate with the pretix server.
|
||||
general-purpose :ref:`rest-api` that not yet provides all features that this API provides, but will do
|
||||
so in the future.
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
Support for check-in-time questions has been added. The new API features are fully backwards-compatible and
|
||||
negotiated live, so clients which do not need this feature can ignore the change. For this reason, the API version
|
||||
has not been increased and is still set to 3.
|
||||
|
||||
|
||||
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
|
||||
|
||||
Redeems a ticket, i.e. checks the user in.
|
||||
@@ -22,18 +29,30 @@ uses to communicate with the pretix server.
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3
|
||||
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3&questions_supported=true
|
||||
|
||||
You can optionally include the additional parameter ``datetime`` in the body containing an ISO8601-encoded
|
||||
datetime of the entry attempt. If you don't, the current date and time will be used.
|
||||
You **must** set the parameter secret.
|
||||
|
||||
You can optionally include the additional parameter ``force`` to indicate that the request should be logged
|
||||
You **must** set the parameter ``questions_supported`` to ``true`` **if** you support asking questions
|
||||
back to the app operator. You **must not** set it if you do not support this feature. In that case, questions
|
||||
will just be ignored.
|
||||
|
||||
You **may** set the additional parameter ``datetime`` in the body containing an ISO8601-encoded
|
||||
datetime of the entry attempt. If you don"t, the current date and time will be used.
|
||||
|
||||
You **may** set the additional parameter ``force`` to indicate that the request should be logged
|
||||
regardless of previous check-ins for the same ticket. This might be useful if you made the entry decision offline.
|
||||
Questions will also always be ignored in this case (i.e. supplied answers will be saved, but no error will be
|
||||
thrown if they are missing or invalid).
|
||||
|
||||
You can optionally include the additional parameter ``nonce`` with a globally unique random value to identify this
|
||||
You **may** set the additional parameter ``nonce`` with a globally unique random value to identify this
|
||||
check-in. This is meant to be used to prevent duplicate check-ins when you are just retrying after a connection
|
||||
failure.
|
||||
|
||||
If questions are supported and required, you will receive a dictionary ``questions`` containing details on the
|
||||
particular questions to ask. To answer them, just re-send your redemption request with additional parameters of
|
||||
the form ``answer_<question>=<answer>``, e.g. ``answer_12=24``.
|
||||
|
||||
**Example successful response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -43,10 +62,66 @@ uses to communicate with the pretix server.
|
||||
|
||||
{
|
||||
"status": "ok"
|
||||
"version": 2
|
||||
"version": 3,
|
||||
"data": {
|
||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
||||
"order": "ABCDE",
|
||||
"item": "Standard ticket",
|
||||
"item_id": 1,
|
||||
"variation": null,
|
||||
"variation_id": null,
|
||||
"attendee_name": "Peter Higgs",
|
||||
"attention": false,
|
||||
"redeemed": true,
|
||||
"paid": true
|
||||
}
|
||||
}
|
||||
|
||||
**Example error response**:
|
||||
**Example response with required questions**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "incomplete"
|
||||
"version": 3
|
||||
"data": {
|
||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
||||
"order": "ABCDE",
|
||||
"item": "Standard ticket",
|
||||
"item_id": 1,
|
||||
"variation": null,
|
||||
"variation_id": null,
|
||||
"attendee_name": "Peter Higgs",
|
||||
"attention": false,
|
||||
"redeemed": true,
|
||||
"paid": true
|
||||
},
|
||||
"questions": [
|
||||
{
|
||||
"id": 12,
|
||||
"type": "C",
|
||||
"question": "Choose a shirt size",
|
||||
"required": true,
|
||||
"position": 2,
|
||||
"items": [1],
|
||||
"options": [
|
||||
{
|
||||
"id": 24,
|
||||
"answer": "M"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"answer": "L"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Example error response with data**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
@@ -56,13 +131,39 @@ uses to communicate with the pretix server.
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "already_redeemed",
|
||||
"version": 2
|
||||
"version": 3,
|
||||
"data": {
|
||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
||||
"order": "ABCDE",
|
||||
"item": "Standard ticket",
|
||||
"item_id": 1,
|
||||
"variation": null,
|
||||
"variation_id": null,
|
||||
"attendee_name": "Peter Higgs",
|
||||
"attention": false,
|
||||
"redeemed": true,
|
||||
"paid": true
|
||||
}
|
||||
}
|
||||
|
||||
**Example error response without data**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "unkown_ticket",
|
||||
"version": 3
|
||||
}
|
||||
|
||||
Possible error reasons:
|
||||
|
||||
* ``unpaid`` - Ticket is not paid for or has been refunded
|
||||
* ``already_redeemed`` - Ticket already has been redeemed
|
||||
* ``product`` - Tickets with this product may not be scanned at this device
|
||||
* ``unknown_ticket`` - Secret does not match a ticket in the database
|
||||
|
||||
:query key: Secret API key
|
||||
@@ -104,7 +205,7 @@ uses to communicate with the pretix server.
|
||||
},
|
||||
...
|
||||
],
|
||||
"version": 2
|
||||
"version": 3
|
||||
}
|
||||
|
||||
:query query: Search query
|
||||
@@ -133,6 +234,7 @@ uses to communicate with the pretix server.
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"version": 3,
|
||||
"results": [
|
||||
{
|
||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
||||
@@ -146,7 +248,26 @@ uses to communicate with the pretix server.
|
||||
},
|
||||
...
|
||||
],
|
||||
"version": 2
|
||||
"questions": [
|
||||
{
|
||||
"id": 12,
|
||||
"type": "C",
|
||||
"question": "Choose a shirt size",
|
||||
"required": true,
|
||||
"position": 2,
|
||||
"items": [1],
|
||||
"options": [
|
||||
{
|
||||
"id": 24,
|
||||
"answer": "M"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"answer": "L"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query key: Secret API key
|
||||
@@ -157,7 +278,7 @@ uses to communicate with the pretix server.
|
||||
.. http:get:: /pretixdroid/api/(organizer)/(event)/status/
|
||||
|
||||
Returns status information, such as the total number of tickets and the
|
||||
number of performed checkins.
|
||||
number of performed check-ins.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -177,7 +298,7 @@ uses to communicate with the pretix server.
|
||||
{
|
||||
"checkins": 17,
|
||||
"total": 42,
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"event": {
|
||||
"name": "Demo Converence",
|
||||
"slug": "democon",
|
||||
|
||||
@@ -3,3 +3,5 @@ sphinx
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
sphinxcontrib-images
|
||||
sphinxcontrib-spelling
|
||||
pyenchant
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 94 KiB |
119
doc/spelling_wordlist.txt
Normal file
119
doc/spelling_wordlist.txt
Normal file
@@ -0,0 +1,119 @@
|
||||
addon
|
||||
addons
|
||||
api
|
||||
auth
|
||||
autobuild
|
||||
backend
|
||||
backends
|
||||
banktransfer
|
||||
boolean
|
||||
booleans
|
||||
cancelled
|
||||
casted
|
||||
checkbox
|
||||
checksum
|
||||
config
|
||||
contenttypes
|
||||
contextmanager
|
||||
cron
|
||||
cronjob
|
||||
debian
|
||||
deduplication
|
||||
discoverable
|
||||
django
|
||||
dockerfile
|
||||
durations
|
||||
eu
|
||||
filename
|
||||
filesystem
|
||||
fontawesome
|
||||
frontend
|
||||
frontpage
|
||||
gettext
|
||||
gunicorn
|
||||
hardcoded
|
||||
hostname
|
||||
invalidations
|
||||
iterable
|
||||
libsass
|
||||
linters
|
||||
memcached
|
||||
metadata
|
||||
middleware
|
||||
mixin
|
||||
mixins
|
||||
multi
|
||||
multidomain
|
||||
namespace
|
||||
namespaced
|
||||
namespaces
|
||||
namespacing
|
||||
natively
|
||||
nginx
|
||||
NotificationType
|
||||
ons
|
||||
optimizations
|
||||
param
|
||||
percental
|
||||
positionid
|
||||
pre
|
||||
prepend
|
||||
prepended
|
||||
prepending
|
||||
preprocessor
|
||||
presale
|
||||
pretix
|
||||
pretixdroid
|
||||
pretixpresale
|
||||
prometheus
|
||||
proxied
|
||||
proxying
|
||||
queryset
|
||||
redemptions
|
||||
redis
|
||||
refactored
|
||||
regex
|
||||
renderer
|
||||
renderers
|
||||
reportlab
|
||||
screenshot
|
||||
serializers
|
||||
serializers
|
||||
sexualized
|
||||
startup
|
||||
stdout
|
||||
stylesheet
|
||||
subdirectories
|
||||
subdirectory
|
||||
subdomain
|
||||
subdomains
|
||||
subevent
|
||||
subevents
|
||||
submodule
|
||||
subpath
|
||||
systemd
|
||||
testutils
|
||||
timestamp
|
||||
un
|
||||
unconfigured
|
||||
unix
|
||||
unprefixed
|
||||
untrusted
|
||||
username
|
||||
url
|
||||
versa
|
||||
viewset
|
||||
viewsets
|
||||
webhook
|
||||
webhooks
|
||||
webserver
|
||||
webservice
|
||||
workflow
|
||||
zipcode
|
||||
Datetime
|
||||
Embeddable
|
||||
Hierarkey
|
||||
OAuth
|
||||
SSL
|
||||
Uptime
|
||||
Yay
|
||||
@@ -30,13 +30,13 @@ available products, quotas, prices and some meta information, but most settings
|
||||
We recommend to use this feature only if you really know that you need it and if you really run a lot of events, not if
|
||||
you run e.g. a yearly conference. You can read more on this feature :ref:`here <subevents>`.
|
||||
|
||||
Once you set these values, you can procede to the next step:
|
||||
Once you set these values, you can proceed to the next step:
|
||||
|
||||
.. thumbnail:: ../../screens/event/create_step2.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
In this step, you will be asked more detailled questions about your event. In particular, you can fill in the
|
||||
In this step, you will be asked more detailed questions about your event. In particular, you can fill in the
|
||||
following fields:
|
||||
|
||||
Name
|
||||
|
||||
@@ -8,13 +8,13 @@ The settings at "Settings" → "Display" allow you to customize the appearance o
|
||||
:class: screenshot
|
||||
|
||||
The upper part of the page contains settings that you always need to set specifically for your event. Those are
|
||||
currently::
|
||||
currently:
|
||||
|
||||
Logo image
|
||||
This logo will be shown as a banner above your shop. If you set it, the event name and date will no longer be
|
||||
displayed by the shop, so we suggest to include them in the image yourself. The maximal height of the image is
|
||||
120 pixels and if you want to use the full width, make your image 1140 pixels wide. If the user's screen is
|
||||
smaller, the logo will be scaled down automatically, so it should still be legigible at smaller sizes.
|
||||
smaller, the logo will be scaled down automatically, so it should still be legible at smaller sizes.
|
||||
|
||||
Frontpage text
|
||||
This text will be shown on the front page of your ticket shop, above the list of products. You can use it to explain
|
||||
@@ -39,4 +39,4 @@ Font
|
||||
Choose one of multiple fonts to use for your web shop.
|
||||
|
||||
.. note:: Both the color and font settings can take a few seconds up to a few minutes before they become active on your
|
||||
shop.
|
||||
shop.
|
||||
|
||||
129
doc/user/events/email.rst
Normal file
129
doc/user/events/email.rst
Normal file
@@ -0,0 +1,129 @@
|
||||
E-mail settings
|
||||
===============
|
||||
|
||||
The settings at "Settings" → "E-mail" allow you to customize the emails that pretix sends to the participants of your
|
||||
event.
|
||||
|
||||
.. thumbnail:: ../../screens/event/settings_email.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
The page is separated into three parts: "E-mail settings", "E-mail content" and "SMTP settings". We will explain all
|
||||
of them in detail on this page.
|
||||
|
||||
E-mail settings
|
||||
---------------
|
||||
|
||||
The upper part of the page contains settings that are relevant for the generation of all e-mails alike. Those are
|
||||
currently:
|
||||
|
||||
Subject prefix
|
||||
This text will be prepended to the subject of all e-mails that are related to your event. For example, if you
|
||||
set this to "dc2018" all subjects will be formatted like "[dc2018] Your payment was successful".
|
||||
|
||||
Sender address
|
||||
All e-mails will be sent with this address in the "From" field. If you use an email address at a custom domain,
|
||||
we strongly recommend to use the SMTP settings below as well, otherwise your e-mails might be detected as spam
|
||||
due to the `Sender Policy Framework`_ and similar mechanisms.
|
||||
|
||||
Signature
|
||||
This text will be appended to all e-mails in form of a signature. This might be useful e.g. to add your contact
|
||||
details or any legal information that needs to be included with the e-mails.
|
||||
|
||||
E-mail content
|
||||
--------------
|
||||
|
||||
The middle part of the page allows you to customize the exact texts of all e-mails sent by the system automatically.
|
||||
You can click on the different boxes to expand them and see the texts.
|
||||
|
||||
Within the texts, you can use placeholders that will later by replaced by values depending on the event or order. Below
|
||||
every text box is a list of supported placeholders, but currently the following are defined (not every placeholder
|
||||
is valid in every text):
|
||||
|
||||
============================== ===============================================================================
|
||||
Placeholder Description
|
||||
============================== ===============================================================================
|
||||
event The event name
|
||||
total The order's total value
|
||||
currency The currency used for the event (three-letter code)
|
||||
payment_info Information text specific to the payment method (e.g. banking details)
|
||||
url An URL pointing to the download/status page of the order
|
||||
invoice_name The name field of the invoice address
|
||||
invoice_company The company field of the invoice address
|
||||
expire_date The order's expiration date
|
||||
date The same as ``expire_date``, but in a different e-mail (for backwards
|
||||
compatibility)
|
||||
orders A list of orders including links to their status pages, specific to the "resend
|
||||
link (requested by user)" e-mail
|
||||
code In case of the waiting list, the voucher code to redeem
|
||||
hours In case of the waiting list, the number of hours the voucher code is valid
|
||||
============================== ===============================================================================
|
||||
|
||||
The different e-mails are explained in the following:
|
||||
|
||||
Placed Order
|
||||
This e-mail is sent out to every order directly after the order has been received, except if the order total
|
||||
is zero (see below). It should specify that/how the order is to be paid.
|
||||
|
||||
Paid Order
|
||||
This e-mail is sent out as soon as the payment for an order has been received and should give the customer
|
||||
more information on how to proceed, e.g. by downloading their ticket.
|
||||
|
||||
Free Order
|
||||
This e-mail is sent out instead of "Placed Order" and "Paid Order" if the order total is zero. It therefore should
|
||||
tell the same information, except asking the customer for completing their payment.
|
||||
|
||||
Resend link
|
||||
Sent by admin
|
||||
This e-mail will be sent out if you click the "Resend link" next to the e-mail address field on the order detail
|
||||
page. It should include the link to the order and can be sent to users e.g. if they lost their original e-mails.
|
||||
|
||||
Requested by user
|
||||
Customers can also request a link to all orders they created using their e-mail address themselves by filling
|
||||
out a form on the website. In this case, they will receive an e-mail containing a list of all orders they created
|
||||
with the respective links.
|
||||
|
||||
Order changed
|
||||
This e-mail is sent out if you change the content of the order and choose to notify the user about it.
|
||||
|
||||
Payment reminder
|
||||
This e-mail is sent out a certain number of days before the order's expiry date. You can specify the number of days
|
||||
before the expiry date that this should happen and the e-mail will only ever be sent if you do specify such a
|
||||
number. The text should ask the customer to complete the payment, tell the options on how to do so and the
|
||||
consequences if no payment is received (ticket gone, depending on your other settings). You should also include
|
||||
a way to contact you in case of questions.
|
||||
|
||||
Waiting list notification
|
||||
If you enable the waiting list feature, this is the mail that will be sent out if a ticket is assigned to a person on
|
||||
the waiting list. It should include the voucher that needs to be redeemed to get the free spot and tell how long
|
||||
that voucher is valid and where to redeem it.
|
||||
|
||||
Order canceled
|
||||
This e-mail is sent to a customer if their order has been canceled.
|
||||
|
||||
|
||||
Order custom mail
|
||||
You can use pretix' admin interface to directly send an e-mail with a custom text to the customer of a specific
|
||||
order. In this case, this will be the default text and might save you time by not having to re-type all of it every
|
||||
time.
|
||||
|
||||
Reminder to download tickets
|
||||
If you want, you can configure an email that will be send out a number of days before your event to remind
|
||||
attendees to download their tickets. The e-mail should include a link to the ticket download. This e-mail will only
|
||||
ever be sent if you specify a number of days.
|
||||
|
||||
SMTP settings
|
||||
-------------
|
||||
|
||||
If you want to send your e-mails via your own e-mail address, we strongly recommend to use SMTP for this purpose.
|
||||
SMTP is a protocol that is used by e-mail clients to communicate with e-mail servers. Using SMTP, pretix can talk to
|
||||
your e-mail service provider the same way that e.g. the e-mail app on your phone can.
|
||||
|
||||
Your e-mail provider will most likely have a document that tells you the settings for the various fields to fill in
|
||||
here (hostname, port, username, password, encryption).
|
||||
|
||||
With the checkbox "Use custom SMTP server" you can turn using your SMTP server on or off completely. With the
|
||||
button "Save and test custom SMTP connection", you can test if the connection and authentication to your SMTP server
|
||||
succeeds, even before turning that checkbox on.
|
||||
|
||||
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
|
||||
96
doc/user/events/invoicing.rst
Normal file
96
doc/user/events/invoicing.rst
Normal file
@@ -0,0 +1,96 @@
|
||||
Invoice settings
|
||||
================
|
||||
|
||||
.. spelling:: Inv
|
||||
|
||||
The settings at "Settings" → "Invoice" allow you to specify if and how pretix should generate invoices for your orders.
|
||||
|
||||
.. thumbnail:: ../../screens/event/settings_invoice.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
In particular, you can configure the following things:
|
||||
|
||||
Ask for invoice address
|
||||
If this checkbox is enabled, customers will be able to enter an invoice address during checkout. If you only enable
|
||||
this box, the invoice address will be optional to fill in.
|
||||
|
||||
Require invoice address
|
||||
If this checkbox is enabled, entering an invoice address will be obligatory for all customers and it will not be
|
||||
able to create an order without entering an address.
|
||||
|
||||
Require customer name
|
||||
If this checkbox is enabled, the street, city, and country fields of the invoice address will still be optional but
|
||||
the name field will be obligatory.
|
||||
|
||||
Generate invoices
|
||||
This field controls whether pretix should generate an invoice for an order. You have the following options:
|
||||
|
||||
No
|
||||
pretix will never generate an invoice. If you want to issue invoices, you need to do it yourself based on the
|
||||
collected address data.
|
||||
|
||||
Manually in admin panel
|
||||
pretix will not create invoices automatically, but the order detail view will show a button that allows you to
|
||||
manually generate one for specific orders.
|
||||
|
||||
Automatically on user request
|
||||
pretix will not create invoices on its own, but both the panel as well as the customer view of the order will
|
||||
show a button that instantly generates an invoice for the specified order.
|
||||
|
||||
Automatically for all created orders
|
||||
pretix will automatically create an invoice every time an order is placed.
|
||||
|
||||
Automatically on payment
|
||||
pretix will automatically create an invoice for an order, as soon as the payment for the order is received.
|
||||
|
||||
pretix will never generate invoices for free orders, even though it might ask for the invoice address.
|
||||
|
||||
Attach invoices to emails
|
||||
If enabled, invoices will be attached to order confirmation e-mails if the "Generate invoices" setting is set to
|
||||
"Automatically for all created orders" or to the payment confirmation e-mails if it is set to "Automatically on
|
||||
payment".
|
||||
|
||||
Ask for VAT ID
|
||||
If enabled, the invoice address form will not only ask for a postal address, but also for a VAT ID. The VAT ID will
|
||||
always be an optional field.
|
||||
|
||||
Generate invoices with consecutive numbers
|
||||
If enabled, invoices will be created with numerical invoice numbers in the order of their creation, i.e.
|
||||
PREFIX-00001, PREFIX-00002, and so on. If disabled, invoice numbers will instead be generated from the order code,
|
||||
i.e. PREFIX-YHASD-1. When in doubt, keep this option enabled since it might be legally required in your country,
|
||||
but disabling it has the advantage that your customers can not estimate the number of tickets sold by looking at
|
||||
the invoice numbers.
|
||||
|
||||
Invoice number prefix
|
||||
This is the prefix that will be prepended to all your invoice numbers. For example, if you set this to "Inv", your
|
||||
invoices will be numbered Inv00001, Inv00002, etc. If you leave this field empty, your event slug will be used,
|
||||
followed by a dash, e.g. DEMOCON-00001.
|
||||
|
||||
Within one organizer account, events with the same number prefix will share their number range. For example, if you
|
||||
set this to "Inv" for all of your events, there will be only one invoice numbered Inv00007 across all your events
|
||||
and the numbers will have gaps within one event.
|
||||
|
||||
Show free products on invoices
|
||||
If enabled, products that do not cost anything will still show up on invoices. Note that the order needs to contain
|
||||
at least one non-free product in order to generate an invoice.
|
||||
|
||||
Show attendee names on invoices
|
||||
If enabled, the attendee name will be printed on the invoice for admission tickets.
|
||||
|
||||
Your address
|
||||
This should be set to the address of the entity issuing the invoice (read: you) and will be printed inside
|
||||
the header of the invoice.
|
||||
|
||||
Introductory text
|
||||
A free custom text that will be printed above the list of products on the invoice.
|
||||
|
||||
Additional text
|
||||
A free custom text that will be printed below the list of products and the invoice total.
|
||||
|
||||
Footer
|
||||
A text that will be printed in the foot line of the invoice. This could contain your contact details or legal
|
||||
information on the issuing entity, e.g. registration numbers, your VAT ID, etc.
|
||||
|
||||
Logo image
|
||||
A square image that will be printed in the invoice header, currently with a width of 2.5cm.
|
||||
@@ -8,4 +8,7 @@ Configuring an event
|
||||
../payments/index
|
||||
plugins
|
||||
display
|
||||
tickets
|
||||
email
|
||||
taxes
|
||||
invoicing
|
||||
|
||||
@@ -103,7 +103,7 @@ End of presale
|
||||
|
||||
Quotas
|
||||
As for all events, no tickets will be available unless there is a quota created for them that specifies the number
|
||||
of tickets available. You can create multiple quotas that are assinged to this date directly from this interface.
|
||||
of tickets available. You can create multiple quotas that are assigned to this date directly from this interface.
|
||||
|
||||
Item prices
|
||||
This is a table of all products configured for your shop. If you want, you can enter a new price for each one of them
|
||||
|
||||
@@ -18,7 +18,7 @@ your event, go to the respective section in your event's settings:
|
||||
:class: screenshot
|
||||
|
||||
On this page, you can create, edit and delete your tax rules. Clicking on the name of a tax rule will take you to its
|
||||
detailled settings:
|
||||
detailed settings:
|
||||
|
||||
.. thumbnail:: ../../screens/event/tax_detail.png
|
||||
:align: center
|
||||
|
||||
30
doc/user/events/tickets.rst
Normal file
30
doc/user/events/tickets.rst
Normal file
@@ -0,0 +1,30 @@
|
||||
Ticket settings
|
||||
===============
|
||||
|
||||
At "Settings" → "Tickets", you can configure the ticket download options that will be presented to your customers:
|
||||
|
||||
.. thumbnail:: ../../screens/event/settings_tickets.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
The top of this page shows a short list of options relevant for all download formats:
|
||||
|
||||
Use feature
|
||||
This can be used to completely enable or disable ticket downloads all over your ticket shop.
|
||||
|
||||
Download date
|
||||
If you set a date here, no ticket download will be offered before this date. If no date is set, tickets can be
|
||||
downloaded immediately after the payment for an order has been received.
|
||||
|
||||
Offer to download tickets separately for add-on products
|
||||
By default, tickets can not be downloaded for order positions which are only an add-on to other order positions. If
|
||||
you enable this, this behavior will be changed and add-on products will get their own tickets as well. If disabled,
|
||||
you can still print a list of chosen add-ons e.g. on the PDF tickets.
|
||||
|
||||
Generate tickets for non-admission products
|
||||
By default, tickets will only be generated for products that are marked as admission products. Enable this option to
|
||||
generate tickets for all products instead.
|
||||
|
||||
Below these settings, the detail settings for the various ticket file formats are offered. They differ from format to
|
||||
format and only share the common "Enable" setting that can be used to turn them on. By default, pretix ships with
|
||||
a PDF output plugin that you can configure through a visual design editor.
|
||||
@@ -67,7 +67,7 @@ SSL
|
||||
---
|
||||
|
||||
Since buying a ticket normally involves entering sensitive data, we strongly suggest that you use SSL/HTTPS for the page
|
||||
that includes the widget. Initiatives like `Let's Encrypt`_ allow you to obtain a SSL certificat free of charge.
|
||||
that includes the widget. Initiatives like `Let's Encrypt`_ allow you to obtain a SSL certificate free of charge.
|
||||
|
||||
All data transferred to pretix will be made over SSL, even if using the widget on a non-SSL site. However, without
|
||||
using SSL for your site, a man-in-the-middle attacker could potentially alter the widget in dangerous ways. Moreover,
|
||||
@@ -75,7 +75,7 @@ using SSL is becoming standard practice and your customers might want expect see
|
||||
granted to SSL-enabled web pages.
|
||||
|
||||
By default, the checkout process will open in a new tab in your customer's browsers if you don't use SSL for your
|
||||
website. If you confident to have a good reason for not using SSL, you can override this behaviour with the
|
||||
website. If you confident to have a good reason for not using SSL, you can override this behavior with the
|
||||
``skip-ssl-check`` attribute::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" skip-ssl-check></pretix-widget>
|
||||
|
||||
@@ -9,7 +9,7 @@ There are multiple ways to do this.
|
||||
First, you could just create some orders in your real shop and cancel/refund them later. If you don't want to process
|
||||
real payments for the tests, you can either use a "manual" payment method like bank transfer and just mark the orders
|
||||
as paid with the button in the backend, or if you want to use e.g. Stripe, you can configure pretix to use your keys
|
||||
for the Stripe test sytem and use their test credit cars. Read our :ref:`Stripe documentation <stripe>` for more
|
||||
for the Stripe test system and use their test credit cars. Read our :ref:`Stripe documentation <stripe>` for more
|
||||
information.
|
||||
|
||||
Second, you could create a separate event, just for testing. In the last step of the :ref:`event creation process <event_create>`,
|
||||
@@ -24,9 +24,11 @@ received any real orders (i.e. taken the shop public). We won't charge any fees
|
||||
How do I delete an event?
|
||||
-------------------------
|
||||
|
||||
It is currently not possible to delete events, you can just disable the shop by clicking the first square on your event
|
||||
dashboard. Events can't be deleted as they most likely contain information on financial transactions which legally
|
||||
needs to be kept on record for multiple years in most countries.
|
||||
You can find the event deletion button at the bottom of the event settings page. Note however, that it is not possible
|
||||
to delete an event once any order or invoice has been created, as those likely contain information on financial
|
||||
transactions which legally may not be tampered with and needs to be kept on record for multiple years in most
|
||||
countries. In this case, you can just disable the shop by clicking the first square on your event
|
||||
dashboard.
|
||||
|
||||
If you are using the hosted service at pretix.eu and want to get rid of an event that you only used for testing, contact
|
||||
us at support@pretix.eu and we can remove it for you.
|
||||
@@ -48,4 +50,4 @@ If you created a product and it doesn't show up, please follow the following ste
|
||||
variation is contained in a quota. If your event is an event series, make sure that the product is contained in a
|
||||
quota that is assigned to the series date that you access the shop for.
|
||||
6. If the sale period has not started yet or is already over, check the "Show items outside presale period" setting of
|
||||
your event.
|
||||
your event.
|
||||
|
||||
@@ -25,7 +25,7 @@ To set a text in italics, you can put it in asterisks or underscores. For exampl
|
||||
|
||||
will become:
|
||||
|
||||
Please *really* pay your _ticket_.
|
||||
Please *really* pay your *ticket*.
|
||||
|
||||
If you set double asterisks or underscores, the text will be printed in bold. For example,
|
||||
|
||||
@@ -145,7 +145,7 @@ to get a better plain text representation of your text. Note however, that for
|
||||
security reasons you can only use the following HTML elements::
|
||||
|
||||
a, abbr, acronym, b, br, code, div, em, h1, h2,
|
||||
h3, h4, h5, h6, hr, i, li, ol, p, span, strong,
|
||||
h3, h4, h5, h6, hr, i, li, ol, p, pre, span, strong,
|
||||
table, tbody, td, thead, tr, ul
|
||||
|
||||
Additionally, only the following attributes are allowed on them::
|
||||
@@ -163,4 +163,4 @@ All other elements and attributes will be stripped during parsing.
|
||||
|
||||
|
||||
.. _Markdown: https://en.wikipedia.org/wiki/Markdown
|
||||
.. _Wikipedia: https://en.wikipedia.org
|
||||
.. _Wikipedia: https://en.wikipedia.org
|
||||
|
||||
@@ -47,11 +47,11 @@ Permissions separate into two areas:
|
||||
* Can change product settings – This permission allows to create and modify products and objects that are closely
|
||||
related to products, such as product categories, quotas, and questions.
|
||||
|
||||
* Can view orders – This permission allows viewing the list of orders and allindividual order details, but not
|
||||
* Can view orders – This permission allows viewing the list of orders and all individual order details, but not
|
||||
changing anything about it. This also includes the various exports offered.
|
||||
|
||||
* Can change orders – This permission allows all actions that involve changing an order, such as changing the products
|
||||
in an order, marking an order as paid or refunden, importing banking data, etc. This only works properly if the
|
||||
in an order, marking an order as paid or refunded, importing banking data, etc. This only works properly if the
|
||||
same users also have the "Can view orders" permission.
|
||||
|
||||
* Can view vouchers – This permission allows viewing the list of vouchers including the voucher codes themselves and
|
||||
|
||||
@@ -60,4 +60,4 @@ same 5 %, such that for a ticket with a list price of 100 € you will get your
|
||||
===================================================== =============
|
||||
|
||||
Due to the various rounding steps performed by pretix and by the payment provider, the end total on
|
||||
your bank account might stil vary by one cent.
|
||||
your bank account might still vary by one cent.
|
||||
|
||||
@@ -2,10 +2,10 @@ Payment method overview
|
||||
=======================
|
||||
|
||||
pretix allows you to accept payments using a variety of payment methods to fit the needs of very different events.
|
||||
This page gives you a short overview over them and links to more detailled descriptions in some cases.
|
||||
This page gives you a short overview over them and links to more detailed descriptions in some cases.
|
||||
|
||||
Payment methods are built as pretix plugins. For this reason, you might first need to enable a certain plugin at
|
||||
"Settings" → "Plugins" in your event settings. Then, you can configure them in detail at "Settings" -> "Payment".
|
||||
"Settings" → "Plugins" in your event settings. Then, you can configure them in detail at "Settings" → "Payment".
|
||||
|
||||
If you host pretix on your own server, you might need to install a plugin first for some of the payment methods listed
|
||||
on this page as well as for additional ones.
|
||||
@@ -13,5 +13,4 @@ on this page as well as for additional ones.
|
||||
To get an overview of the officially supported payment methods and their pros and cons, head to the `pretix website`_.
|
||||
On these pages, you get more information on how to configure :ref:`stripe`, :ref:`paypal`, and :ref:`banktransfer`.
|
||||
|
||||
|
||||
.. _pretix website: https://pretix.eu/about/en/payments
|
||||
.. _pretix website: https://pretix.eu/about/en/features/payment
|
||||
|
||||
@@ -11,7 +11,7 @@ of the page shows a number of general settings that affect all payment methods:
|
||||
In particular, these are:
|
||||
|
||||
Payment term in days
|
||||
If a order has been created, it is supposed to be paid within this number of days. Of course, some payment mehtods
|
||||
If a order has been created, it is supposed to be paid within this number of days. Of course, some payment methods
|
||||
(like credit card) succeed immediately in most cases, but others don't (like bank transfer) and even credit card
|
||||
payments might fail and you might want to give the customer a chance to try another credit card before losing their
|
||||
ticket. Therefore, we recommend setting a few days here. If you are accepting bank transfers, we wouldn't recommend
|
||||
@@ -19,7 +19,7 @@ Payment term in days
|
||||
|
||||
Last date of payments
|
||||
There is probably no use for payments received after your event, so you can set a date that the payment deadline of
|
||||
a new order will never exceed. This has precendence over the number of days configured above, so if I create an order
|
||||
a new order will never exceed. This has precedence over the number of days configured above, so if I create an order
|
||||
two days before the configured last date of payments, my payment term will only be two days, not ten. If you have
|
||||
payment methods that always require some time (like bank transfer), you will later be able to selectively disable them
|
||||
once the event comes closer.
|
||||
|
||||
34
res/logo.svg
34
res/logo.svg
@@ -9,15 +9,16 @@
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="294.15625"
|
||||
height="149.59375"
|
||||
width="600"
|
||||
height="400"
|
||||
id="svg2"
|
||||
version="1.1"
|
||||
inkscape:version="0.91 r13725"
|
||||
inkscape:version="0.92.1 r"
|
||||
sodipodi:docname="logo.svg"
|
||||
inkscape:export-filename="/home/raphael/proj/pretix/pretix/logo_draft.png"
|
||||
inkscape:export-xdpi="88.529999"
|
||||
inkscape:export-ydpi="88.529999">
|
||||
inkscape:export-filename="/tmp/LOGO.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
viewBox="0 0 562.50001 375.00002">
|
||||
<defs
|
||||
id="defs4" />
|
||||
<sodipodi:namedview
|
||||
@@ -28,14 +29,14 @@
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.9899495"
|
||||
inkscape:cx="134.70089"
|
||||
inkscape:cy="277.43904"
|
||||
inkscape:cx="133.36756"
|
||||
inkscape:cy="276.10571"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="636"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1041"
|
||||
inkscape:window-x="3200"
|
||||
inkscape:window-x="1920"
|
||||
inkscape:window-y="18"
|
||||
inkscape:window-maximized="0"
|
||||
fit-margin-top="20"
|
||||
@@ -58,11 +59,14 @@
|
||||
inkscape:label="Ebene 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-257.78125,-548.75)">
|
||||
transform="translate(-259.03125,-322.09374)">
|
||||
<path
|
||||
style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:#3b1c4a;fill-opacity:1;fill-rule:nonzero;stroke:none;marker:none;enable-background:accumulate"
|
||||
d="M 20 20 L 20 54.09375 C 31.43679 54.09375 40.71875 63.37571 40.71875 74.8125 C 40.71875 86.24928 31.43679 95.5 20 95.5 L 20 129.59375 L 166.6875 129.59375 L 166.6875 120.09375 L 169.6875 120.09375 L 169.6875 129.59375 L 274.15625 129.59375 L 274.15625 95.5 C 274.14575 95.50002 274.1354 95.5 274.125 95.5 C 262.68822 95.5 253.40625 86.24928 253.40625 74.8125 C 253.40625 63.37571 262.68822 54.09375 274.125 54.09375 C 274.1355 54.09375 274.14585 54.09373 274.15625 54.09375 L 274.15625 20 L 169.6875 20 L 169.6875 29.09375 L 166.6875 29.09375 L 166.6875 20 L 20 20 z M 166.6875 36.09375 L 169.6875 36.09375 L 169.6875 50.09375 L 166.6875 50.09375 L 166.6875 36.09375 z M 208.12891 48.927734 C 210.91958 48.927734 213.15234 50.855474 213.15234 53.240234 C 213.15234 55.624994 210.91958 57.603516 208.12891 57.603516 C 205.38897 57.603516 203.15625 55.624994 203.15625 53.240234 C 203.15625 50.855474 205.38897 48.927734 208.12891 48.927734 z M 194.90039 53.138672 L 194.90039 61.15625 L 198.85938 61.15625 L 198.85938 67.447266 L 194.90039 67.447266 L 194.90039 79.726562 C 194.90039 81.756152 195.61054 82.517578 197.03125 82.517578 C 197.7416 82.517578 198.09828 82.415768 198.85938 82.111328 L 198.85938 88.046875 C 198.14902 88.452785 196.47277 89.011719 194.24023 89.011719 C 188.10074 89.011719 185.25977 85.257843 185.25977 80.539062 L 185.25977 67.447266 L 182.41797 67.447266 L 182.41797 61.15625 L 185.25977 61.15625 L 185.25977 55.574219 L 194.90039 53.138672 z M 166.6875 57.09375 L 169.6875 57.09375 L 169.6875 71.09375 L 166.6875 71.09375 L 166.6875 57.09375 z M 92.119141 60.648438 C 100.59265 60.648438 106.32617 65.164126 106.32617 74.753906 C 106.32617 83.379636 101.30281 88.859375 94.25 88.859375 C 92.52486 88.859375 91.102928 88.656085 90.392578 88.453125 L 90.392578 99.414062 L 80.751953 99.414062 L 80.751953 62.728516 C 83.339673 61.510766 86.842221 60.648435 92.119141 60.648438 z M 141.98242 60.648438 C 150.55741 60.648438 154.61678 66.583801 154.10938 75.869141 L 138.17773 78.103516 C 138.78661 81.046406 140.35834 82.517578 143.85938 82.517578 C 147.1067 82.517578 149.64383 81.806022 151.16602 81.044922 L 153.29688 86.931641 C 150.91212 88.098651 147.71654 89.011719 142.64258 89.011719 C 133.71241 89.011719 128.99414 82.973726 128.99414 74.753906 C 128.99414 66.534096 133.40743 60.648438 141.98242 60.648438 z M 124.06055 60.654297 C 124.95335 60.667874 125.8885 60.69926 126.86523 60.75 L 125.18945 67.447266 C 123.41356 66.584696 121.68841 66.533574 120.41992 66.990234 L 120.41992 88.503906 L 110.7793 88.503906 L 110.7793 62.728516 C 113.57632 61.352202 117.81096 60.559256 124.06055 60.654297 z M 203.30859 61.15625 L 212.94922 61.15625 L 212.94922 88.503906 L 203.30859 88.503906 L 203.30859 61.15625 z M 216.54297 61.15625 L 226.58984 61.15625 L 229.88867 68.005859 L 229.99023 68.005859 L 233.5918 61.15625 L 242.57227 61.15625 L 234.60742 73.789062 L 243.33398 88.503906 L 232.67969 88.503906 L 229.17773 80.943359 L 229.07617 80.943359 L 225.42383 88.503906 L 215.68164 88.503906 L 224.25586 74.398438 L 216.54297 61.15625 z M 141.57617 66.179688 C 138.73475 66.179688 137.16236 68.765636 137.4668 73.535156 L 145.23047 72.369141 C 145.23047 68.208501 144.01167 66.179687 141.57617 66.179688 z M 92.068359 66.279297 C 91.358009 66.279297 90.849228 66.380983 90.392578 66.533203 L 90.392578 82.972656 C 90.747748 83.124866 91.256406 83.226562 91.916016 83.226562 C 95.366316 83.226562 96.787109 80.386048 96.787109 74.804688 C 96.787109 69.071117 95.569389 66.279297 92.068359 66.279297 z M 166.6875 78.09375 L 169.6875 78.09375 L 169.6875 92.09375 L 166.6875 92.09375 L 166.6875 78.09375 z M 166.6875 99.09375 L 169.6875 99.09375 L 169.6875 113.09375 L 166.6875 113.09375 L 166.6875 99.09375 z "
|
||||
transform="translate(257.78125,548.75)"
|
||||
id="rect3888" />
|
||||
style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:#3b1c4a;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.91138947;marker:none;enable-background:accumulate"
|
||||
d="m 297.38548,404.85558 v 65.16643 c 21.86016,0 39.6016,17.74144 39.6016,39.60159 0,21.86015 -17.74144,39.54187 -39.6016,39.54187 v 65.16644 h 280.37693 v -18.1582 h 5.73417 v 18.1582 h 199.68046 v -65.16644 c -0.02,4e-5 -0.0397,0 -0.0596,0 -21.86015,0 -39.6016,-17.68172 -39.6016,-39.54187 0,-21.86015 17.74145,-39.60159 39.6016,-39.60159 0.02,0 0.0397,-4e-5 0.0596,0 V 404.85558 H 583.49658 v 17.38169 h -5.73417 V 404.85558 Z M 577.76241,435.617 h 5.73417 v 26.75945 h -5.73417 z m 79.21068,24.53074 c 5.33405,0 9.60172,3.68466 9.60172,8.24287 0,4.5582 -4.26767,8.33993 -9.60172,8.33993 -5.2371,0 -9.50469,-3.78173 -9.50469,-8.33993 0,-4.55821 4.26759,-8.24287 9.50469,-8.24287 z m -25.28486,8.04874 v 15.32472 h 7.56717 v 12.02458 h -7.56717 v 23.47051 c 0,3.87934 1.35737,5.33473 4.0729,5.33473 1.35775,0 2.03951,-0.19461 3.49427,-0.77651 v 11.34514 c -1.35777,0.77585 -4.56174,1.84419 -8.829,1.84419 -11.73495,0 -17.16515,-7.17511 -17.16515,-16.19455 v -25.02351 h -5.43179 V 483.5212 h 5.43179 v -10.66944 z m -53.92582,7.5597 h 5.73417 v 26.75945 h -5.73417 z m -142.52917,6.79439 c 16.19618,0 27.15517,8.63124 27.15517,26.96104 0,16.48713 -9.6016,26.96105 -23.08227,26.96105 -3.29741,0 -6.01528,-0.38857 -7.37304,-0.77651 v 20.95062 H 413.50612 V 486.5264 c 4.94614,-2.32759 11.64087,-3.97583 21.72712,-3.97583 z m 95.30815,0 c 16.39014,0 24.14917,11.34479 23.17933,29.09269 l -30.45158,4.27076 c 1.1638,5.62501 4.16799,8.437 10.85985,8.437 6.20689,0 11.05633,-1.36007 13.96583,-2.81482 l 4.0729,11.2518 c -4.5582,2.23062 -10.6662,3.97584 -20.36451,3.97584 -17.06903,0 -26.08749,-11.54096 -26.08749,-27.25223 0,-15.71126 8.43552,-26.96104 24.82567,-26.96104 z m -34.25568,0.0113 c 1.70649,0.026 3.49392,0.0859 5.36084,0.18292 l -3.20307,12.80108 c -3.39442,-1.6487 -6.69185,-1.74642 -9.11643,-0.87356 v 41.121 h -18.42698 v -49.2668 c 5.3462,-2.63068 13.44024,-4.1463 25.38564,-3.96465 z m 151.47387,0.95943 h 18.42699 v 52.27202 h -18.42699 z m 25.29605,0 h 19.20348 l 6.30535,13.09227 h 0.19412 l 6.884,-13.09227 h 17.16517 l -15.22393,24.14622 16.67986,28.1258 h -20.3645 l -6.69361,-14.45115 h -0.19412 l -6.98104,14.45115 h -18.62112 l 16.38867,-26.96104 z m -143.29075,9.60175 c -5.43106,0 -8.43651,4.94275 -7.85461,14.05916 l 14.8394,-2.22871 c 0,-7.9526 -2.3296,-11.83045 -6.98479,-11.83045 z m -94.6287,0.19038 c -1.35776,0 -2.33024,0.19437 -3.20308,0.48532 v 31.4222 c 0.67888,0.29093 1.65112,0.48531 2.91189,0.48531 6.59487,0 9.31055,-5.42933 9.31055,-16.09748 0,-10.95908 -2.32753,-16.29535 -9.01936,-16.29535 z m 142.62623,22.58194 h 5.73417 v 26.75946 h -5.73417 z m 0,40.13918 h 5.73417 v 26.75946 h -5.73417 z"
|
||||
id="rect3888"
|
||||
inkscape:connector-curvature="0"
|
||||
inkscape:export-filename="/tmp/LOGO.png"
|
||||
inkscape:export-xdpi="88"
|
||||
inkscape:export-ydpi="88" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 4.8 KiB |
@@ -1 +1 @@
|
||||
__version__ = "1.10.0"
|
||||
__version__ = "1.12.1"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
@@ -16,11 +19,44 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
||||
'position', 'default_price', 'price')
|
||||
|
||||
|
||||
class ItemVariationSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price')
|
||||
|
||||
|
||||
class InlineItemAddOnSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemAddOn
|
||||
fields = ('addon_category', 'min_count', 'max_count',
|
||||
'position')
|
||||
'position', 'price_included')
|
||||
|
||||
|
||||
class ItemAddOnSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemAddOn
|
||||
fields = ('id', 'addon_category', 'min_count', 'max_count',
|
||||
'position', 'price_included')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
ItemAddOn.clean_max_min_count(data.get('max_count'), data.get('min_count'))
|
||||
|
||||
return data
|
||||
|
||||
def validate_min_count(self, value):
|
||||
ItemAddOn.clean_min_count(value)
|
||||
return value
|
||||
|
||||
def validate_max_count(self, value):
|
||||
ItemAddOn.clean_max_count(value)
|
||||
return value
|
||||
|
||||
def validate_addon_category(self, value):
|
||||
ItemAddOn.clean_categories(self.context['event'], self.context['item'], self.instance, value)
|
||||
return value
|
||||
|
||||
|
||||
class ItemTaxRateField(serializers.Field):
|
||||
@@ -32,8 +68,8 @@ class ItemTaxRateField(serializers.Field):
|
||||
|
||||
|
||||
class ItemSerializer(I18nAwareModelSerializer):
|
||||
addons = InlineItemAddOnSerializer(many=True)
|
||||
variations = InlineItemVariationSerializer(many=True)
|
||||
addons = InlineItemAddOnSerializer(many=True, required=False)
|
||||
variations = InlineItemVariationSerializer(many=True, required=False)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -44,6 +80,55 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
|
||||
'variations', 'addons')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"has_variations": self.kwargs['has_variations']}
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
|
||||
Item.clean_available(data.get('available_from'), data.get('available_until'))
|
||||
|
||||
return data
|
||||
|
||||
def validate_category(self, value):
|
||||
Item.clean_category(value, self.context['event'])
|
||||
return value
|
||||
|
||||
def validate_tax_rule(self, value):
|
||||
Item.clean_tax_rule(value, self.context['event'])
|
||||
return value
|
||||
|
||||
def validate_variations(self, value):
|
||||
if self.instance is not None:
|
||||
raise ValidationError(_('Updating variations via PATCH/PUT is not supported. Please use the dedicated'
|
||||
' nested endpoint.'))
|
||||
return value
|
||||
|
||||
def validate_addons(self, value):
|
||||
if self.instance is not None:
|
||||
raise ValidationError(_('Updating add-ons via PATCH/PUT is not supported. Please use the dedicated'
|
||||
' nested endpoint.'))
|
||||
else:
|
||||
for addon_data in value:
|
||||
ItemAddOn.clean_categories(self.context['event'], None, self.instance, addon_data['addon_category'])
|
||||
ItemAddOn.clean_min_count(addon_data['min_count'])
|
||||
ItemAddOn.clean_max_count(addon_data['max_count'])
|
||||
ItemAddOn.clean_max_min_count(addon_data['max_count'], addon_data['min_count'])
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
variations_data = validated_data.pop('variations') if 'variations' in validated_data else {}
|
||||
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
|
||||
item = Item.objects.create(**validated_data)
|
||||
for variation_data in variations_data:
|
||||
ItemVariation.objects.create(item=item, **variation_data)
|
||||
for addon_data in addons_data:
|
||||
ItemAddOn.objects.create(base_item=item, **addon_data)
|
||||
return item
|
||||
|
||||
|
||||
class ItemCategorySerializer(I18nAwareModelSerializer):
|
||||
@@ -65,7 +150,8 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position')
|
||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
|
||||
'ask_during_checkin')
|
||||
|
||||
|
||||
class QuotaSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -38,7 +38,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
class CheckinSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = ('datetime',)
|
||||
fields = ('datetime', 'list')
|
||||
|
||||
|
||||
class OrderDownloadsField(serializers.Field):
|
||||
|
||||
@@ -26,6 +26,13 @@ event_router.register(r'taxrules', event.TaxRuleViewSet)
|
||||
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
||||
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
||||
|
||||
checkinlist_router = routers.DefaultRouter()
|
||||
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet)
|
||||
|
||||
item_router = routers.DefaultRouter()
|
||||
item_router.register(r'variations', item.ItemVariationViewSet)
|
||||
item_router.register(r'addons', item.ItemAddOnViewSet)
|
||||
|
||||
# Force import of all plugins to give them a chance to register URLs with the router
|
||||
for app in apps.get_app_configs():
|
||||
if hasattr(app, 'PretixPluginMeta'):
|
||||
@@ -36,4 +43,7 @@ urlpatterns = [
|
||||
url(r'^', include(router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/', include(orga_router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/', include(event_router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/items/(?P<item>[^/]+)/', include(item_router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/checkinlists/(?P<list>[^/]+)/',
|
||||
include(checkinlist_router.urls)),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from rest_framework.filters import OrderingFilter
|
||||
|
||||
|
||||
class RichOrderingFilter(OrderingFilter):
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
ordering = self.get_ordering(request, queryset, view)
|
||||
|
||||
if ordering:
|
||||
if hasattr(view, 'ordering_custom'):
|
||||
newo = []
|
||||
for ordering_part in ordering:
|
||||
ob = view.ordering_custom.get(ordering_part)
|
||||
if ob:
|
||||
ob = dict(ob)
|
||||
newo.append(ob.pop('_order'))
|
||||
queryset = queryset.annotate(**ob)
|
||||
else:
|
||||
newo.append(ordering_part)
|
||||
ordering = newo
|
||||
return queryset.order_by(*ordering)
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import django_filters
|
||||
from django.db.models import F, Max, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import viewsets
|
||||
|
||||
from pretix.api.serializers.checkin import CheckinListSerializer
|
||||
from pretix.base.models import CheckinList
|
||||
from pretix.api.serializers.order import OrderPositionSerializer
|
||||
from pretix.api.views import RichOrderingFilter
|
||||
from pretix.base.models import Checkin, CheckinList, Order, OrderPosition
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.helpers.database import FixedOrderBy
|
||||
|
||||
|
||||
class CheckinListFilter(FilterSet):
|
||||
@@ -57,3 +65,79 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class OrderPositionFilter(FilterSet):
|
||||
order = django_filters.CharFilter(name='order', lookup_expr='code')
|
||||
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
|
||||
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
return queryset.filter(last_checked_in__isnull=not value)
|
||||
|
||||
def attendee_name_qs(self, queryset, name, value):
|
||||
return queryset.filter(Q(attendee_name=value) | Q(addon_to__attendee_name=value))
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ['item', 'variation', 'attendee_name', 'secret', 'order', 'has_checkin', 'addon_to', 'subevent']
|
||||
|
||||
|
||||
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPositionSerializer
|
||||
queryset = OrderPosition.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
|
||||
ordering = ('attendee_name', 'positionid')
|
||||
ordering_fields = (
|
||||
'order__code', 'order__datetime', 'positionid', 'attendee_name',
|
||||
'last_checked_in', 'order__email',
|
||||
)
|
||||
ordering_custom = {
|
||||
'attendee_name': {
|
||||
'_order': F('display_name').asc(nulls_first=True),
|
||||
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')
|
||||
},
|
||||
'-attendee_name': {
|
||||
'_order': F('display_name').desc(nulls_last=True),
|
||||
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')
|
||||
},
|
||||
'last_checked_in': {
|
||||
'_order': FixedOrderBy(F('last_checked_in'), nulls_first=True),
|
||||
},
|
||||
'-last_checked_in': {
|
||||
'_order': FixedOrderBy(F('last_checked_in'), nulls_last=True, descending=True),
|
||||
},
|
||||
}
|
||||
|
||||
filter_class = OrderPositionFilter
|
||||
permission = 'can_view_orders'
|
||||
|
||||
@cached_property
|
||||
def checkinlist(self):
|
||||
return get_object_or_404(CheckinList, event=self.request.event, pk=self.kwargs.get("list"))
|
||||
|
||||
def get_queryset(self):
|
||||
cqs = Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
list_id=self.checkinlist.pk
|
||||
).order_by().values('position_id').annotate(
|
||||
m=Max('datetime')
|
||||
).values('m')
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.request.event,
|
||||
order__status=Order.STATUS_PAID,
|
||||
subevent=self.checkinlist.subevent
|
||||
).annotate(
|
||||
last_checked_in=Subquery(cqs)
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
)
|
||||
).select_related('item', 'variation', 'order', 'addon_to')
|
||||
|
||||
if not self.checkinlist.all_products:
|
||||
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
|
||||
|
||||
return qs
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.item import (
|
||||
ItemCategorySerializer, ItemSerializer, QuestionSerializer,
|
||||
QuotaSerializer,
|
||||
ItemAddOnSerializer, ItemCategorySerializer, ItemSerializer,
|
||||
ItemVariationSerializer, QuestionSerializer, QuotaSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Item, ItemAddOn, ItemCategory, ItemVariation, Question, Quota,
|
||||
)
|
||||
from pretix.base.models import Item, ItemCategory, Question, Quota
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
|
||||
class ItemFilter(FilterSet):
|
||||
@@ -28,7 +33,7 @@ class ItemFilter(FilterSet):
|
||||
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
|
||||
|
||||
|
||||
class ItemViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class ItemViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ItemSerializer
|
||||
queryset = Item.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
@@ -36,10 +41,159 @@ class ItemViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ordering = ('position', 'id')
|
||||
filter_class = ItemFilter
|
||||
permission = 'can_change_items'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.items.select_related('tax_rule').prefetch_related('variations', 'addons').all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.item.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['has_variations'] = self.request.data.get('has_variations')
|
||||
return ctx
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.item.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied('This item cannot be deleted because it has already been ordered '
|
||||
'by a user or currently is in a users\'s cart. Please set the item as '
|
||||
'"inactive" instead.')
|
||||
|
||||
instance.log_action(
|
||||
'pretix.event.item.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ItemVariationSerializer
|
||||
queryset = ItemVariation.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('id',)
|
||||
permission = 'can_change_items'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
return item.variations.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['item'] = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
if not item.has_variations:
|
||||
raise PermissionDenied('This variation cannot be created because the item does not have variations. '
|
||||
'Changing a product without variations to a product with variations is not allowed.')
|
||||
serializer.save(item=item)
|
||||
item.log_action(
|
||||
'pretix.event.item.variation.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk},
|
||||
{'value': serializer.instance.value})
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.item.log_action(
|
||||
'pretix.event.item.variation.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk},
|
||||
{'value': serializer.instance.value})
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied('This variation cannot be deleted because it has already been ordered '
|
||||
'by a user or currently is in a users\'s cart. Please set the variation as '
|
||||
'\'inactive\' instead.')
|
||||
if instance.is_only_variation():
|
||||
raise PermissionDenied('This variation cannot be deleted because it is the only variation. Changing a '
|
||||
'product with variations to a product without variations is not allowed.')
|
||||
super().perform_destroy(instance)
|
||||
instance.item.log_action(
|
||||
'pretix.event.item.variation.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data={
|
||||
'value': instance.value,
|
||||
'id': self.kwargs['pk']
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ItemAddOnSerializer
|
||||
queryset = ItemAddOn.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('id',)
|
||||
permission = 'can_change_items'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
return item.addons.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['item'] = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category'])
|
||||
serializer.save(base_item=item, addon_category=category)
|
||||
item.log_action(
|
||||
'pretix.event.item.addons.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.base_item.log_action(
|
||||
'pretix.event.item.addons.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
super().perform_destroy(instance)
|
||||
instance.base_item.log_action(
|
||||
'pretix.event.item.addons.removed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data={'category': instance.addon_category.pk}
|
||||
)
|
||||
|
||||
|
||||
class ItemCategoryFilter(FilterSet):
|
||||
class Meta:
|
||||
|
||||
@@ -21,7 +21,8 @@ from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.services.invoices import invoice_pdf
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.orders import (
|
||||
OrderError, cancel_order, extend_order, mark_order_paid,
|
||||
OrderError, cancel_order, extend_order, mark_order_expired,
|
||||
mark_order_paid,
|
||||
)
|
||||
from pretix.base.services.tickets import (
|
||||
get_cachedticket_for_order, get_cachedticket_for_position,
|
||||
@@ -109,9 +110,9 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
|
||||
order = self.get_object()
|
||||
if order.status != Order.STATUS_PENDING:
|
||||
if not order.cancel_allowed():
|
||||
return Response(
|
||||
{'detail': 'The order is not pending.'},
|
||||
{'detail': 'The order is not allowed to be canceled.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
@@ -153,10 +154,8 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
order.status = Order.STATUS_EXPIRED
|
||||
order.save()
|
||||
order.log_action(
|
||||
'pretix.event.order.expired',
|
||||
mark_order_expired(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
|
||||
)
|
||||
|
||||
@@ -11,7 +11,8 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import payment # NOQA
|
||||
from . import exporters # NOQA
|
||||
from . import invoice # NOQA
|
||||
from .services import export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas # NOQA
|
||||
from . import notifications # NOQA
|
||||
from .services import export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA
|
||||
|
||||
try:
|
||||
from .celery_app import app as celery_app # NOQA
|
||||
|
||||
@@ -25,7 +25,7 @@ class BaseExporter:
|
||||
"""
|
||||
A short and unique identifier for this exporter.
|
||||
This should only contain lowercase letters and in most
|
||||
cases will be the same as your packagename.
|
||||
cases will be the same as your package name.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import os
|
||||
import tempfile
|
||||
from collections import OrderedDict
|
||||
from zipfile import ZipFile
|
||||
|
||||
import dateutil.parser
|
||||
from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
@@ -15,9 +18,26 @@ class InvoiceExporter(BaseExporter):
|
||||
verbose_name = _('All invoices')
|
||||
|
||||
def render(self, form_data: dict):
|
||||
qs = self.event.invoices.all()
|
||||
|
||||
if form_data.get('payment_provider'):
|
||||
qs = qs.filter(order__payment_provider=form_data.get('payment_provider'))
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(date__gte=date_value)
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(date__lte=date_value)
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for i in self.event.invoices.all():
|
||||
for i in qs:
|
||||
if not i.file:
|
||||
invoice_pdf_task.apply(args=(i.pk,))
|
||||
i.refresh_from_db()
|
||||
@@ -26,7 +46,44 @@ class InvoiceExporter(BaseExporter):
|
||||
i.file.close()
|
||||
|
||||
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
|
||||
return 'invoices.zip', 'application/zip', zipf.read()
|
||||
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read()
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=_('Only include invoices issued on or after this date. Note that the invoice date does '
|
||||
'not always correspond to the order or payment date.')
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=_('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=_('Only include invoices issued on or before this date. Note that the invoice date '
|
||||
'does not always correspond to the order or payment date.')
|
||||
)),
|
||||
('payment_provider',
|
||||
forms.ChoiceField(
|
||||
label=_('Payment provider'),
|
||||
choices=[
|
||||
('', _('All payment providers')),
|
||||
] + [
|
||||
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
|
||||
],
|
||||
required=False,
|
||||
help_text=_('Only include invoices for orders that are currently set to this payment provider. '
|
||||
'Note that this might include some invoices of other payment providers or misses '
|
||||
'some invoices if the payment provider of an order has been changed and a new invoice '
|
||||
'has been generated.')
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_invoices")
|
||||
|
||||
@@ -101,7 +101,7 @@ class JSONExporter(BaseExporter):
|
||||
}
|
||||
}
|
||||
|
||||
return 'pretixdata.json', 'application/json', json.dumps(jo, cls=DjangoJSONEncoder)
|
||||
return '{}_pretixdata.json'.format(self.event.slug), 'application/json', json.dumps(jo, cls=DjangoJSONEncoder)
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_json")
|
||||
|
||||
@@ -23,7 +23,7 @@ class MailExporter(BaseExporter):
|
||||
).values('attendee_email')
|
||||
data = "\r\n".join(set(a['email'] for a in addrs)
|
||||
| set(a['attendee_email'] for a in pos if a['attendee_email']))
|
||||
return 'pretixemails.txt', 'text/plain', data.encode("utf-8")
|
||||
return '{}_pretixemails.txt'.format(self.event.slug), 'text/plain', data.encode("utf-8")
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
|
||||
@@ -171,7 +171,7 @@ class QuotaListExporter(BaseExporter):
|
||||
]
|
||||
writer.writerow(row)
|
||||
|
||||
return 'quotas.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
return '{}_quotas.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
|
||||
|
||||
236
src/pretix/base/forms/questions.py
Normal file
236
src/pretix/base/forms/questions.py
Normal file
@@ -0,0 +1,236 @@
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
import dateutil.parser
|
||||
import pytz
|
||||
import vat_moss.errors
|
||||
import vat_moss.id
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms.widgets import (
|
||||
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
|
||||
TimePickerWidget, UploadedFileWidget,
|
||||
)
|
||||
from pretix.base.models import InvoiceAddress, Question
|
||||
from pretix.base.models.tax import EU_COUNTRIES
|
||||
from pretix.helpers.i18n import get_format_without_seconds
|
||||
from pretix.presale.signals import question_form_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseQuestionsForm(forms.Form):
|
||||
"""
|
||||
This form class is responsible for asking order-related questions. This includes
|
||||
the attendee name for admission tickets, if the corresponding setting is enabled,
|
||||
as well as additional questions defined by the organizer.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Takes two additional keyword arguments:
|
||||
|
||||
:param cartpos: The cart position the form should be for
|
||||
:param event: The event this belongs to
|
||||
"""
|
||||
cartpos = self.cartpos = kwargs.pop('cartpos', None)
|
||||
orderpos = self.orderpos = kwargs.pop('orderpos', None)
|
||||
pos = cartpos or orderpos
|
||||
item = pos.item
|
||||
questions = pos.item.questions_to_ask
|
||||
event = kwargs.pop('event')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if item.admission and event.settings.attendee_names_asked:
|
||||
self.fields['attendee_name'] = forms.CharField(
|
||||
max_length=255, required=event.settings.attendee_names_required,
|
||||
label=_('Attendee name'),
|
||||
initial=(cartpos.attendee_name if cartpos else orderpos.attendee_name),
|
||||
)
|
||||
if item.admission and event.settings.attendee_emails_asked:
|
||||
self.fields['attendee_email'] = forms.EmailField(
|
||||
required=event.settings.attendee_emails_required,
|
||||
label=_('Attendee email'),
|
||||
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email)
|
||||
)
|
||||
|
||||
for q in questions:
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
answers = [a for a in pos.answerlist if a.question_id == q.id]
|
||||
if answers:
|
||||
initial = answers[0]
|
||||
else:
|
||||
initial = None
|
||||
tz = pytz.timezone(event.settings.timezone)
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
if q.required:
|
||||
# For some reason, django-bootstrap3 does not set the required attribute
|
||||
# itself.
|
||||
widget = forms.CheckboxInput(attrs={'required': 'required'})
|
||||
else:
|
||||
widget = forms.CheckboxInput()
|
||||
|
||||
if initial:
|
||||
initialbool = (initial.answer == "True")
|
||||
else:
|
||||
initialbool = False
|
||||
|
||||
field = forms.BooleanField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=initialbool, widget=widget,
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
min_value=Decimal('0.00'),
|
||||
)
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
widget=forms.Select,
|
||||
empty_label='',
|
||||
initial=initial.options.first() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=q.options,
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
initial=initial.options.all() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_FILE:
|
||||
field = forms.FileField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
field = forms.DateField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
|
||||
widget=DatePickerWidget(),
|
||||
)
|
||||
elif q.type == Question.TYPE_TIME:
|
||||
field = forms.TimeField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).time() if initial and initial.answer else None,
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
field = forms.SplitDateTimeField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
|
||||
widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
field.question = q
|
||||
if answers:
|
||||
# Cache the answer object for later use
|
||||
field.answer = answers[0]
|
||||
self.fields['question_%s' % q.id] = field
|
||||
|
||||
responses = question_form_fields.send(sender=event, position=pos)
|
||||
data = pos.meta_info_data
|
||||
for r, response in sorted(responses, key=lambda r: str(r[0])):
|
||||
for key, value in response.items():
|
||||
# We need to be this explicit, since OrderedDict.update does not retain ordering
|
||||
self.fields[key] = value
|
||||
value.initial = data.get('question_form_data', {}).get(key)
|
||||
|
||||
|
||||
class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
vat_warning = False
|
||||
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||
'internal_reference')
|
||||
widgets = {
|
||||
'is_business': BusinessBooleanRadio,
|
||||
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
|
||||
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'name': forms.TextInput(attrs={}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'internal_reference': forms.TextInput,
|
||||
}
|
||||
labels = {
|
||||
'is_business': ''
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = event = kwargs.pop('event')
|
||||
self.request = kwargs.pop('request', None)
|
||||
self.validate_vat_id = kwargs.pop('validate_vat_id')
|
||||
super().__init__(*args, **kwargs)
|
||||
if not event.settings.invoice_address_vatid:
|
||||
del self.fields['vat_id']
|
||||
if not event.settings.invoice_address_required:
|
||||
for k, f in self.fields.items():
|
||||
f.required = False
|
||||
f.widget.is_required = False
|
||||
if 'required' in f.widget.attrs:
|
||||
del f.widget.attrs['required']
|
||||
|
||||
if event.settings.invoice_name_required:
|
||||
self.fields['name'].required = True
|
||||
else:
|
||||
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
|
||||
self.fields['name'].widget.attrs['data-required-if'] = '#id_is_business_0'
|
||||
|
||||
def clean(self):
|
||||
data = self.cleaned_data
|
||||
if not data.get('name') and not data.get('company') and self.event.settings.invoice_address_required:
|
||||
raise ValidationError(_('You need to provide either a company name or your name.'))
|
||||
|
||||
if 'vat_id' in self.changed_data or not data.get('vat_id'):
|
||||
self.instance.vat_id_validated = False
|
||||
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass
|
||||
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
|
||||
if data.get('vat_id')[:2] != str(data.get('country')):
|
||||
raise ValidationError(_('Your VAT ID does not match the selected country.'))
|
||||
try:
|
||||
result = vat_moss.id.validate(data.get('vat_id'))
|
||||
if result:
|
||||
country_code, normalized_id, company_name = result
|
||||
self.instance.vat_id_validated = True
|
||||
self.instance.vat_id = normalized_id
|
||||
except vat_moss.errors.InvalidError:
|
||||
raise ValidationError(_('This VAT ID is not valid. Please re-check your input.'))
|
||||
except vat_moss.errors.WebServiceUnavailableError:
|
||||
logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
|
||||
self.instance.vat_id_validated = False
|
||||
if self.request and self.vat_warning:
|
||||
messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of '
|
||||
'your country is currently not available. We will therefore '
|
||||
'need to charge VAT on your invoice. You can get the tax amount '
|
||||
'back via the VAT reimbursement process.'))
|
||||
else:
|
||||
self.instance.vat_id_validated = False
|
||||
@@ -23,6 +23,12 @@ class PlaceholderValidator(BaseValidator):
|
||||
self.__call__(v)
|
||||
return
|
||||
|
||||
if value.count('{') != value.count('}'):
|
||||
raise ValidationError(
|
||||
_('Invalid placeholder syntax: You used a different number of "{" than of "}".'),
|
||||
code='invalid',
|
||||
)
|
||||
|
||||
data_placeholders = list(re.findall(r'({[\w\s]*})', value, re.X))
|
||||
invalid_placeholders = []
|
||||
for placeholder in data_placeholders:
|
||||
|
||||
135
src/pretix/base/forms/widgets.py
Normal file
135
src/pretix/base/forms/widgets.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import os
|
||||
|
||||
from django import forms
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
|
||||
|
||||
class DatePickerWidget(forms.DateInput):
|
||||
def __init__(self, attrs=None, date_format=None):
|
||||
attrs = attrs or {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
date_attrs = dict(attrs)
|
||||
date_attrs.setdefault('class', 'form-control')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
date_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
|
||||
forms.DateInput.__init__(self, date_attrs, date_format)
|
||||
|
||||
|
||||
class TimePickerWidget(forms.TimeInput):
|
||||
def __init__(self, attrs=None, time_format=None):
|
||||
attrs = attrs or {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
time_attrs = dict(attrs)
|
||||
time_attrs.setdefault('class', 'form-control')
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
time_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
|
||||
forms.TimeInput.__init__(self, time_attrs, time_format)
|
||||
|
||||
|
||||
class UploadedFileWidget(forms.ClearableFileInput):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.position = kwargs.pop('position')
|
||||
self.event = kwargs.pop('event')
|
||||
self.answer = kwargs.pop('answer')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class FakeFile:
|
||||
def __init__(self, file, position, event, answer):
|
||||
self.file = file
|
||||
self.position = position
|
||||
self.event = event
|
||||
self.answer = answer
|
||||
|
||||
def __str__(self):
|
||||
return os.path.basename(self.file.name).split('.', 1)[-1]
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
if isinstance(self.position, OrderPosition):
|
||||
return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={
|
||||
'order': self.position.order.code,
|
||||
'secret': self.position.order.secret,
|
||||
'answer': self.answer.pk,
|
||||
})
|
||||
else:
|
||||
return eventreverse(self.event, 'presale:event.cart.download.answer', kwargs={
|
||||
'answer': self.answer.pk,
|
||||
})
|
||||
|
||||
def format_value(self, value):
|
||||
if self.is_initial(value):
|
||||
return self.FakeFile(value, self.position, self.event, self.answer)
|
||||
|
||||
|
||||
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
template_name = 'pretixbase/forms/widgets/splitdatetime.html'
|
||||
|
||||
def __init__(self, attrs=None, date_format=None, time_format=None):
|
||||
attrs = attrs or {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
date_attrs = dict(attrs)
|
||||
time_attrs = dict(attrs)
|
||||
date_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
time_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
date_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
time_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
|
||||
widgets = (
|
||||
forms.DateInput(attrs=date_attrs, format=date_format),
|
||||
forms.TimeInput(attrs=time_attrs, format=time_format),
|
||||
)
|
||||
# Skip one hierarchy level
|
||||
forms.MultiWidget.__init__(self, widgets, attrs)
|
||||
|
||||
|
||||
class BusinessBooleanRadio(forms.RadioSelect):
|
||||
def __init__(self, attrs=None):
|
||||
choices = (
|
||||
('individual', _('Individual customer')),
|
||||
('business', _('Business customer')),
|
||||
)
|
||||
super().__init__(attrs, choices)
|
||||
|
||||
def format_value(self, value):
|
||||
try:
|
||||
return {True: 'business', False: 'individual'}[value]
|
||||
except KeyError:
|
||||
return 'individual'
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
value = data.get(name)
|
||||
return {
|
||||
'business': True,
|
||||
True: True,
|
||||
'True': True,
|
||||
'individual': False,
|
||||
'False': False,
|
||||
False: False,
|
||||
}.get(value)
|
||||
@@ -321,6 +321,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
]
|
||||
|
||||
def _get_story(self, doc):
|
||||
has_taxes = any(il.tax_value for il in self.invoice.lines.all())
|
||||
|
||||
story = [
|
||||
NextPageTemplate('FirstPage'),
|
||||
Paragraph(pgettext('invoice', 'Invoice')
|
||||
@@ -352,28 +354,52 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
]
|
||||
tdata = [(
|
||||
pgettext('invoice', 'Description'),
|
||||
pgettext('invoice', 'Tax rate'),
|
||||
pgettext('invoice', 'Net'),
|
||||
pgettext('invoice', 'Gross'),
|
||||
)]
|
||||
if has_taxes:
|
||||
tdata = [(
|
||||
pgettext('invoice', 'Description'),
|
||||
pgettext('invoice', 'Qty'),
|
||||
pgettext('invoice', 'Tax rate'),
|
||||
pgettext('invoice', 'Net'),
|
||||
pgettext('invoice', 'Gross'),
|
||||
)]
|
||||
else:
|
||||
tdata = [(
|
||||
pgettext('invoice', 'Description'),
|
||||
pgettext('invoice', 'Qty'),
|
||||
pgettext('invoice', 'Amount'),
|
||||
)]
|
||||
|
||||
total = Decimal('0.00')
|
||||
for line in self.invoice.lines.all():
|
||||
tdata.append((
|
||||
Paragraph(line.description, self.stylesheet['Normal']),
|
||||
localize(line.tax_rate) + " %",
|
||||
localize(line.net_value) + " " + self.invoice.event.currency,
|
||||
localize(line.gross_value) + " " + self.invoice.event.currency,
|
||||
))
|
||||
if has_taxes:
|
||||
tdata.append((
|
||||
Paragraph(line.description, self.stylesheet['Normal']),
|
||||
"1",
|
||||
localize(line.tax_rate) + " %",
|
||||
localize(line.net_value) + " " + self.invoice.event.currency,
|
||||
localize(line.gross_value) + " " + self.invoice.event.currency,
|
||||
))
|
||||
else:
|
||||
tdata.append((
|
||||
Paragraph(line.description, self.stylesheet['Normal']),
|
||||
"1",
|
||||
localize(line.gross_value) + " " + self.invoice.event.currency,
|
||||
))
|
||||
taxvalue_map[line.tax_rate, line.tax_name] += line.tax_value
|
||||
grossvalue_map[line.tax_rate, line.tax_name] += line.gross_value
|
||||
total += line.gross_value
|
||||
|
||||
tdata.append([
|
||||
pgettext('invoice', 'Invoice total'), '', '', localize(total) + " " + self.invoice.event.currency
|
||||
])
|
||||
colwidths = [a * doc.width for a in (.55, .15, .15, .15)]
|
||||
if has_taxes:
|
||||
tdata.append([
|
||||
pgettext('invoice', 'Invoice total'), '', '', '', localize(total) + " " + self.invoice.event.currency
|
||||
])
|
||||
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
|
||||
else:
|
||||
tdata.append([
|
||||
pgettext('invoice', 'Invoice total'), '', localize(total) + " " + self.invoice.event.currency
|
||||
])
|
||||
colwidths = [a * doc.width for a in (.65, .05, .30)]
|
||||
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=1)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
story.append(table)
|
||||
@@ -422,7 +448,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
except ValueError:
|
||||
return localize(val) + ' ' + self.invoice.foreign_currency_display
|
||||
|
||||
if len(tdata) > 1:
|
||||
if len(tdata) > 1 and has_taxes:
|
||||
colwidths = [a * doc.width for a in (.25, .15, .15, .15, .3)]
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
|
||||
@@ -7,7 +7,6 @@ from django.core.urlresolvers import get_script_prefix
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.translation import LANGUAGE_SESSION_KEY
|
||||
from django.utils.translation.trans_real import (
|
||||
@@ -166,9 +165,6 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'/api/v1/docs/',
|
||||
)
|
||||
|
||||
def process_request(self, request):
|
||||
request.csp_nonce = get_random_string(length=32)
|
||||
|
||||
def process_response(self, request, resp):
|
||||
if settings.DEBUG and resp.status_code >= 400:
|
||||
# Don't use CSP on debug error page as it breaks of Django's fancy error
|
||||
@@ -183,7 +179,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
# frame-src is deprecated but kept for compatibility with CSP 1.0 browsers, e.g. Safari 9
|
||||
'frame-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'child-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'style-src': ["{static}", "{media}", "'nonce-{nonce}'"],
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}", "https://checkout.stripe.com"],
|
||||
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"],
|
||||
'font-src': ["{static}"],
|
||||
@@ -222,10 +218,9 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
if request.path not in self.CSP_EXEMPT and not getattr(resp, '_csp_ignore', False):
|
||||
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain,
|
||||
media=mediadomain, nonce=request.csp_nonce)
|
||||
media=mediadomain)
|
||||
for k, v in h.items():
|
||||
h[k] = ' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain,
|
||||
nonce=request.csp_nonce).split(' ')
|
||||
h[k] = ' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' ')
|
||||
resp['Content-Security-Policy'] = _render_csp(h)
|
||||
elif 'Content-Security-Policy' in resp:
|
||||
del resp['Content-Security-Policy']
|
||||
|
||||
59
src/pretix/base/migrations/0078_auto_20171206_1603.py
Normal file
59
src/pretix/base/migrations/0078_auto_20171206_1603.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.2 on 2017-12-06 16:03
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.auth
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0077_auto_20171124_1629'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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=set([('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),
|
||||
),
|
||||
]
|
||||
39
src/pretix/base/migrations/0079_auto_20180115_0855.py
Normal file
39
src/pretix/base/migrations/0079_auto_20180115_0855.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.8 on 2018-01-15 08:55
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F
|
||||
from django.db.models.functions import Concat
|
||||
|
||||
|
||||
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'))
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0078_auto_20171206_1603'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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(
|
||||
set_full_invoice_no,
|
||||
migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.8 on 2018-01-15 14:26
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0079_auto_20180115_0855'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='ask_during_checkin',
|
||||
field=models.BooleanField(default=False, help_text='Supported by pretixdroid 1.8 and newer or pretixdesk 0.2 and newer.', verbose_name='Ask during check-in instead of during registration'),
|
||||
),
|
||||
]
|
||||
@@ -12,6 +12,7 @@ from .items import (
|
||||
Quota, SubEventItem, SubEventItemVariation, itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .notifications import NotificationSetting
|
||||
from .orders import (
|
||||
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
|
||||
InvoiceAddress, Order, OrderPosition, QuestionAnswer,
|
||||
|
||||
@@ -4,9 +4,11 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser, BaseUserManager, PermissionsMixin,
|
||||
)
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django_otp.models import Device
|
||||
|
||||
@@ -40,6 +42,10 @@ class UserManager(BaseUserManager):
|
||||
return user
|
||||
|
||||
|
||||
def generate_notifications_token():
|
||||
return get_random_string(length=32)
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
"""
|
||||
This is the user model used by pretix for authentication.
|
||||
@@ -80,7 +86,16 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
timezone = models.CharField(max_length=100,
|
||||
default=settings.TIME_ZONE,
|
||||
verbose_name=_('Timezone'))
|
||||
require_2fa = models.BooleanField(default=False)
|
||||
require_2fa = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Two-factor authentification is required to log in')
|
||||
)
|
||||
notifications_send = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_('Receive notifications according to my settings below'),
|
||||
help_text=_('If turned off, you will not get any notifications.')
|
||||
)
|
||||
notifications_token = models.CharField(max_length=255, default=generate_notifications_token)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
@@ -147,6 +162,19 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
except SendMailException:
|
||||
pass # Already logged
|
||||
|
||||
def send_password_reset(self):
|
||||
from pretix.base.services.mail import mail
|
||||
|
||||
mail(
|
||||
self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
|
||||
{
|
||||
'user': self,
|
||||
'url': (build_absolute_uri('control:auth.forgot.recover')
|
||||
+ '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self)))
|
||||
},
|
||||
None, locale=self.locale
|
||||
)
|
||||
|
||||
@property
|
||||
def all_logentries(self):
|
||||
from pretix.base.models import LogEntry
|
||||
|
||||
@@ -47,6 +47,8 @@ class LoggingMixin:
|
||||
"""
|
||||
from .log import LogEntry
|
||||
from .event import Event
|
||||
from ..notifications import get_all_notification_types
|
||||
from ..services.notifications import notify
|
||||
|
||||
event = None
|
||||
if isinstance(self, Event):
|
||||
@@ -60,6 +62,9 @@ class LoggingMixin:
|
||||
logentry.data = json.dumps(data, cls=CustomJSONEncoder)
|
||||
logentry.save()
|
||||
|
||||
if action in get_all_notification_types():
|
||||
notify.apply_async(args=(logentry.pk,))
|
||||
|
||||
|
||||
class LoggedModel(models.Model, LoggingMixin):
|
||||
|
||||
|
||||
@@ -17,7 +17,18 @@ class CheckinList(LoggedModel):
|
||||
|
||||
@staticmethod
|
||||
def annotate_with_numbers(qs, event):
|
||||
from . import Order, OrderPosition
|
||||
"""
|
||||
Modifies a queryset of checkin lists by annotating it with the number of order positions and
|
||||
checkins associated with it.
|
||||
"""
|
||||
# Import here to prevent circular import
|
||||
from . import Order, OrderPosition, Item
|
||||
|
||||
# This is the mother of all subqueries. Sorry. I try to explain it, at least?
|
||||
# First, we prepare a subquery that for every check-in that belongs to a paid-order
|
||||
# position and to the list in question. Then, we check that it also belongs to the
|
||||
# correct subevent (just to be sure) and aggregate over lists (so, over everything,
|
||||
# since we filtered by lists).
|
||||
cqs = Checkin.objects.filter(
|
||||
position__order__event=event,
|
||||
position__order__status=Order.STATUS_PAID,
|
||||
@@ -30,6 +41,11 @@ class CheckinList(LoggedModel):
|
||||
).order_by().values('list').annotate(
|
||||
c=Count('*')
|
||||
).values('c')
|
||||
|
||||
# Now for the hard part: getting all order positions that contribute to this list. This
|
||||
# requires us to use TWO subqueries. The first one, pqs_all, will only be used for check-in
|
||||
# lists that contain all the products of the event. This is the simpler one, it basically
|
||||
# looks like the check-in counter above.
|
||||
pqs_all = OrderPosition.objects.filter(
|
||||
order__event=event,
|
||||
order__status=Order.STATUS_PAID,
|
||||
@@ -41,10 +57,16 @@ class CheckinList(LoggedModel):
|
||||
).order_by().values('order__event').annotate(
|
||||
c=Count('*')
|
||||
).values('c')
|
||||
|
||||
# Now we need a subquery for the case of checkin lists that are limited to certain
|
||||
# products. We cannot use OuterRef("limit_products") since that would do a cross-product
|
||||
# with the products table and we'd get duplicate rows in the output with different annotations
|
||||
# on them, which isn't useful at all. Therefore, we need to add a second layer of subqueries
|
||||
# to retrieve all of those items and then check if the item_id is IN this subquery result.
|
||||
pqs_limited = OrderPosition.objects.filter(
|
||||
order__event=event,
|
||||
order__status=Order.STATUS_PAID,
|
||||
item__in=OuterRef('limit_products')
|
||||
item_id__in=Subquery(Item.objects.filter(checkinlist__pk=OuterRef(OuterRef('pk'))).values('pk'))
|
||||
).filter(
|
||||
# This assumes that in an event with subevents, *all* positions have subevents
|
||||
# and *all* checkin lists have a subevent assigned
|
||||
@@ -54,6 +76,9 @@ class CheckinList(LoggedModel):
|
||||
c=Count('*')
|
||||
).values('c')
|
||||
|
||||
# Finally, we put all of this together. We force empty subquery aggregates to 0 by using Coalesce()
|
||||
# and decide which subquery to use for this row. In the end, we compute an integer percentage in case
|
||||
# we want to display a progress bar.
|
||||
return qs.annotate(
|
||||
checkin_count=Coalesce(Subquery(cqs, output_field=models.IntegerField()), 0),
|
||||
position_count=Coalesce(Case(
|
||||
@@ -75,7 +100,7 @@ class CheckinList(LoggedModel):
|
||||
|
||||
class Checkin(models.Model):
|
||||
"""
|
||||
A checkin 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')
|
||||
datetime = models.DateTimeField(default=now)
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.core.files.storage import default_storage
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.template.defaultfilters import date as _date
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
@@ -26,7 +26,7 @@ from pretix.helpers.daterange import daterange
|
||||
from pretix.helpers.json import safe_string
|
||||
|
||||
from ..settings import settings_hierarkey
|
||||
from .organizer import Organizer
|
||||
from .organizer import Organizer, Team
|
||||
|
||||
|
||||
class EventMixin:
|
||||
@@ -323,6 +323,10 @@ class Event(EventMixin, LoggedModel):
|
||||
else:
|
||||
return get_connection(fail_silently=False)
|
||||
|
||||
@property
|
||||
def timezone(self):
|
||||
return pytz.timezone(self.settings.timezone)
|
||||
|
||||
@property
|
||||
def payment_term_last(self):
|
||||
"""
|
||||
@@ -511,6 +515,42 @@ class Event(EventMixin, LoggedModel):
|
||||
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
|
||||
return data
|
||||
|
||||
def get_users_with_any_permission(self):
|
||||
"""
|
||||
Returns a queryset of users who have any permission to this event.
|
||||
|
||||
:return: Iterable of User
|
||||
"""
|
||||
return self.get_users_with_permission(None)
|
||||
|
||||
def get_users_with_permission(self, permission):
|
||||
"""
|
||||
Returns a queryset of users who have a specific permission to this event.
|
||||
|
||||
:return: Iterable of User
|
||||
"""
|
||||
from .auth import User
|
||||
|
||||
if permission:
|
||||
kwargs = {permission: True}
|
||||
else:
|
||||
kwargs = {}
|
||||
|
||||
team_with_perm = Team.objects.filter(
|
||||
members__pk=OuterRef('pk'),
|
||||
organizer=self.organizer,
|
||||
**kwargs
|
||||
).filter(
|
||||
Q(all_events=True) | Q(limit_events__pk=self.pk)
|
||||
)
|
||||
|
||||
return User.objects.annotate(twp=Exists(team_with_perm)).filter(
|
||||
Q(is_superuser=True) | Q(twp=True)
|
||||
)
|
||||
|
||||
def allow_delete(self):
|
||||
return not self.orders.exists() and not self.invoices.exists()
|
||||
|
||||
|
||||
class SubEvent(EventMixin, LoggedModel):
|
||||
"""
|
||||
@@ -606,6 +646,9 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
|
||||
return data
|
||||
|
||||
def allow_delete(self):
|
||||
return self.event.subevents.count() > 1
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
if self.event:
|
||||
@@ -665,6 +708,21 @@ class RequiredAction(models.Model):
|
||||
return response
|
||||
return self.action_type
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
created = not self.pk
|
||||
super().save(*args, **kwargs)
|
||||
if created:
|
||||
from .log import LogEntry
|
||||
from ..services.notifications import notify
|
||||
|
||||
logentry = LogEntry.objects.create(
|
||||
content_object=self,
|
||||
action_type='pretix.event.action_required',
|
||||
event=self.event,
|
||||
visible=False
|
||||
)
|
||||
notify.apply_async(args=(logentry.pk,))
|
||||
|
||||
|
||||
class EventMetaProperty(LoggedModel):
|
||||
"""
|
||||
|
||||
@@ -41,6 +41,8 @@ class Invoice(models.Model):
|
||||
:type invoice_from: str
|
||||
:param invoice_to: The receiver address
|
||||
:type invoice_to: str
|
||||
:param full_invoice_no: The full invoice number (for performance reasons only)
|
||||
:type full_invoice_no: str
|
||||
:param date: The invoice date
|
||||
:type date: date
|
||||
:param locale: The locale in which the invoice should be printed
|
||||
@@ -55,9 +57,9 @@ class Invoice(models.Model):
|
||||
:type footer_text: str
|
||||
:param foreign_currency_display: A different currency that taxes should also be displayed in.
|
||||
:type foreign_currency_display: str
|
||||
:param foreign_currency_rate: The rate of a forein currency that the taxes should be displayed in.
|
||||
:param foreign_currency_rate: The rate of a foreign currency that the taxes should be displayed in.
|
||||
:type foreign_currency_rate: Decimal
|
||||
:param foreign_currency_rate_date: The date of the forein currency exchange rates.
|
||||
:param foreign_currency_rate_date: The date of the foreign currency exchange rates.
|
||||
:type foreign_currency_rate_date: date
|
||||
:param file: The filename of the rendered invoice
|
||||
:type file: File
|
||||
@@ -67,6 +69,7 @@ class Invoice(models.Model):
|
||||
event = models.ForeignKey('Event', related_name='invoices', db_index=True)
|
||||
prefix = models.CharField(max_length=160, db_index=True)
|
||||
invoice_no = models.CharField(max_length=19, db_index=True)
|
||||
full_invoice_no = models.CharField(max_length=190, db_index=True)
|
||||
is_cancellation = models.BooleanField(default=False)
|
||||
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True)
|
||||
invoice_from = models.TextField()
|
||||
@@ -122,6 +125,8 @@ class Invoice(models.Model):
|
||||
# Suppress duplicate key errors and try again
|
||||
if i == 9:
|
||||
raise
|
||||
|
||||
self.full_invoice_no = self.prefix + self.invoice_no
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal, DecimalException
|
||||
from typing import Tuple
|
||||
|
||||
import dateutil.parser
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Func, Q, Sum
|
||||
from django.utils import formats
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import is_naive, make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
@@ -179,7 +182,7 @@ class Item(LoggedModel):
|
||||
:type max_per_order: int
|
||||
:param min_per_order: Minimum number of times this item needs to be in an order if bought at all. None for unlimited.
|
||||
:type min_per_order: int
|
||||
:param checkin_attention: Requires special attention at checkin
|
||||
:param checkin_attention: Requires special attention at check-in
|
||||
:type checkin_attention: bool
|
||||
"""
|
||||
|
||||
@@ -369,10 +372,41 @@ class Item(LoggedModel):
|
||||
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
|
||||
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import CartPosition, OrderPosition
|
||||
|
||||
return (
|
||||
not OrderPosition.objects.filter(item=self).exists()
|
||||
and not CartPosition.objects.filter(item=self).exists()
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def has_variations(self):
|
||||
return self.variations.exists()
|
||||
|
||||
@staticmethod
|
||||
def clean_per_order(min_per_order, max_per_order):
|
||||
if min_per_order is not None and max_per_order is not None:
|
||||
if min_per_order > max_per_order:
|
||||
raise ValidationError(_('The maximum number per order can not be lower than the minimum number per '
|
||||
'order.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_category(category, event):
|
||||
if category is not None and category.event is not None and category.event != event:
|
||||
raise ValidationError(_('The item\'s category must belong to the same event as the item.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_tax_rule(tax_rule, event):
|
||||
if tax_rule is not None and tax_rule.event is not None and tax_rule.event != event:
|
||||
raise ValidationError(_('The item\'s tax rule must belong to the same event as the item.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_available(from_date, until_date):
|
||||
if from_date is not None and until_date is not None:
|
||||
if from_date > until_date:
|
||||
raise ValidationError(_('The item\'s availability cannot end before it starts.'))
|
||||
|
||||
|
||||
class ItemVariation(models.Model):
|
||||
"""
|
||||
@@ -476,6 +510,17 @@ class ItemVariation(models.Model):
|
||||
return self.id < other.id
|
||||
return self.position < other.position
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import CartPosition, OrderPosition
|
||||
|
||||
return (
|
||||
not OrderPosition.objects.filter(variation=self).exists()
|
||||
and not CartPosition.objects.filter(variation=self).exists()
|
||||
)
|
||||
|
||||
def is_only_variation(self):
|
||||
return ItemVariation.objects.filter(item=self.item).count() == 1
|
||||
|
||||
|
||||
class ItemAddOn(models.Model):
|
||||
"""
|
||||
@@ -527,8 +572,34 @@ class ItemAddOn(models.Model):
|
||||
ordering = ('position', 'pk')
|
||||
|
||||
def clean(self):
|
||||
if self.max_count < self.min_count:
|
||||
raise ValidationError(_('The minimum number needs to be lower than the maximum number.'))
|
||||
self.clean_min_count(self.min_count)
|
||||
self.clean_max_count(self.max_count)
|
||||
self.clean_max_min_count(self.max_count, self.min_count)
|
||||
|
||||
@staticmethod
|
||||
def clean_categories(event, item, addon, new_category):
|
||||
if event != new_category.event:
|
||||
raise ValidationError(_('The add-on\'s category must belong to the same event as the item.'))
|
||||
if item is not None:
|
||||
if addon is None or addon.addon_category != new_category:
|
||||
for addon in item.addons.all():
|
||||
if addon.addon_category == new_category:
|
||||
raise ValidationError(_('The item already has an add-on of this category.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_min_count(min_count):
|
||||
if min_count < 0:
|
||||
raise ValidationError(_('The minimum count needs to be equal to or greater than zero.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_max_count(max_count):
|
||||
if max_count < 0:
|
||||
raise ValidationError(_('The maximum count needs to be equal to or greater than zero.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_max_min_count(max_count, min_count):
|
||||
if max_count < min_count:
|
||||
raise ValidationError(_('The maximum count needs to be greater than the minimum count.'))
|
||||
|
||||
|
||||
class Question(LoggedModel):
|
||||
@@ -543,17 +614,22 @@ class Question(LoggedModel):
|
||||
* a multi-line string (``TYPE_TEXT``)
|
||||
* a boolean (``TYPE_BOOLEAN``)
|
||||
* a multiple choice option (``TYPE_CHOICE`` and ``TYPE_CHOICE_MULTIPLE``)
|
||||
* a file upload (``TYPE_FILE``))
|
||||
* a file upload (``TYPE_FILE``)
|
||||
* a date (``TYPE_DATE``)
|
||||
* a time (``TYPE_TIME``)
|
||||
* a date and a time (``TYPE_DATETIME``)
|
||||
|
||||
:param event: The event this question belongs to
|
||||
:type event: Event
|
||||
:param question: The question text. This will be displayed next to the input field.
|
||||
:type question: str
|
||||
:param type: One of the above types
|
||||
:param required: Whether answering this question is required for submiting an order including
|
||||
:param required: Whether answering this question is required for submitting an order including
|
||||
items associated with this question.
|
||||
:type required: bool
|
||||
:param items: A set of ``Items`` objects that this question should be applied to
|
||||
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
|
||||
:type ask_during_checkin: bool
|
||||
"""
|
||||
TYPE_NUMBER = "N"
|
||||
TYPE_STRING = "S"
|
||||
@@ -562,6 +638,9 @@ class Question(LoggedModel):
|
||||
TYPE_CHOICE = "C"
|
||||
TYPE_CHOICE_MULTIPLE = "M"
|
||||
TYPE_FILE = "F"
|
||||
TYPE_DATE = "D"
|
||||
TYPE_TIME = "H"
|
||||
TYPE_DATETIME = "W"
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_NUMBER, _("Number")),
|
||||
(TYPE_STRING, _("Text (one line)")),
|
||||
@@ -570,6 +649,9 @@ class Question(LoggedModel):
|
||||
(TYPE_CHOICE, _("Choose one from a list")),
|
||||
(TYPE_CHOICE_MULTIPLE, _("Choose multiple from a list")),
|
||||
(TYPE_FILE, _("File upload")),
|
||||
(TYPE_DATE, _("Date")),
|
||||
(TYPE_TIME, _("Time")),
|
||||
(TYPE_DATETIME, _("Date and time")),
|
||||
)
|
||||
|
||||
event = models.ForeignKey(
|
||||
@@ -603,6 +685,12 @@ class Question(LoggedModel):
|
||||
position = models.IntegerField(
|
||||
default=0
|
||||
)
|
||||
ask_during_checkin = models.BooleanField(
|
||||
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
|
||||
help_text=_('This will only work if you handle your check-in with pretixdroid 1.8 or newer or '
|
||||
'pretixdesk 0.2 or newer.'),
|
||||
default=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Question")
|
||||
@@ -629,6 +717,64 @@ class Question(LoggedModel):
|
||||
def __lt__(self, other) -> bool:
|
||||
return self.sortkey < other.sortkey
|
||||
|
||||
def clean_answer(self, answer):
|
||||
if self.required:
|
||||
if not answer or (self.type == Question.TYPE_BOOLEAN and answer not in ("true", "True", True)):
|
||||
raise ValidationError(_('An answer to this question is required to proceed.'))
|
||||
if not answer:
|
||||
if self.type == Question.TYPE_BOOLEAN:
|
||||
return False
|
||||
return None
|
||||
|
||||
if self.type == Question.TYPE_CHOICE:
|
||||
try:
|
||||
return self.options.get(pk=answer)
|
||||
except:
|
||||
raise ValidationError(_('Invalid option selected.'))
|
||||
elif self.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
try:
|
||||
if isinstance(answer, str):
|
||||
return list(self.options.filter(pk__in=answer.split(",")))
|
||||
else:
|
||||
return list(self.options.filter(pk__in=answer))
|
||||
except:
|
||||
raise ValidationError(_('Invalid option selected.'))
|
||||
elif self.type == Question.TYPE_BOOLEAN:
|
||||
return answer in ('true', 'True', True)
|
||||
elif self.type == Question.TYPE_NUMBER:
|
||||
answer = formats.sanitize_separators(answer)
|
||||
answer = str(answer).strip()
|
||||
try:
|
||||
return Decimal(answer)
|
||||
except DecimalException:
|
||||
raise ValidationError(_('Invalid number input.'))
|
||||
elif self.type == Question.TYPE_DATE:
|
||||
if isinstance(answer, date):
|
||||
return answer
|
||||
try:
|
||||
return dateutil.parser.parse(answer).date()
|
||||
except:
|
||||
raise ValidationError(_('Invalid date input.'))
|
||||
elif self.type == Question.TYPE_TIME:
|
||||
if isinstance(answer, time):
|
||||
return answer
|
||||
try:
|
||||
return dateutil.parser.parse(answer).time()
|
||||
except:
|
||||
raise ValidationError(_('Invalid time input.'))
|
||||
elif self.type == Question.TYPE_DATETIME and answer:
|
||||
if isinstance(answer, datetime):
|
||||
return answer
|
||||
try:
|
||||
dt = dateutil.parser.parse(answer)
|
||||
if is_naive(dt):
|
||||
dt = make_aware(dt, pytz.timezone(self.event.settings.timezone))
|
||||
return dt
|
||||
except:
|
||||
raise ValidationError(_('Invalid datetime input.'))
|
||||
|
||||
return answer
|
||||
|
||||
|
||||
class QuestionOption(models.Model):
|
||||
question = models.ForeignKey('Question', related_name='options')
|
||||
@@ -667,7 +813,7 @@ class Quota(LoggedModel):
|
||||
again if those people do not proceed to the checkout.
|
||||
|
||||
AVAILABILITY_ORDERED
|
||||
This item is currently not availalbe for sale because all available
|
||||
This item is currently not available for sale because all available
|
||||
items are ordered. It might become available again if those people
|
||||
do not pay.
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from pretix.base.signals import logentry_object_link
|
||||
|
||||
|
||||
class VisibleOnlyManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(visible=True)
|
||||
|
||||
|
||||
class LogEntry(models.Model):
|
||||
"""
|
||||
Represents a change or action that has been performed on another object
|
||||
@@ -36,9 +41,13 @@ class LogEntry(models.Model):
|
||||
datetime = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
user = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT)
|
||||
api_token = models.ForeignKey('TeamAPIToken', null=True, blank=True, on_delete=models.PROTECT)
|
||||
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.CASCADE)
|
||||
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
action_type = models.CharField(max_length=255)
|
||||
data = models.TextField(default='{}')
|
||||
visible = models.BooleanField(default=True)
|
||||
|
||||
objects = VisibleOnlyManager()
|
||||
all = models.Manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('-datetime',)
|
||||
|
||||
36
src/pretix/base/models/notifications.py
Normal file
36
src/pretix/base/models/notifications.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class NotificationSetting(models.Model):
|
||||
"""
|
||||
Stores that a user wants to get notifications of a certain type via a certain
|
||||
method for a certain event. If event is None, the notification shall be sent
|
||||
for all events the user has access to.
|
||||
|
||||
:param user: The user to nofify.
|
||||
:type user: User
|
||||
:param action_type: The type of action to notify for.
|
||||
:type action_type: str
|
||||
:param event: The event to notify for.
|
||||
:type event: Event
|
||||
:param method: The method to notify with.
|
||||
:type method: str
|
||||
:param enabled: Indicates whether the specified notification is enabled. If no
|
||||
event is set, this must always be true. If no event is set, setting
|
||||
this to false is equivalent to deleting the object.
|
||||
:type enabled: bool
|
||||
"""
|
||||
CHANNELS = (
|
||||
('mail', _('E-mail')),
|
||||
)
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE,
|
||||
related_name='notification_settings')
|
||||
action_type = models.CharField(max_length=255)
|
||||
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.CASCADE,
|
||||
related_name='notification_settings')
|
||||
method = models.CharField(max_length=255, choices=CHANNELS)
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'action_type', 'event', 'method')
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime, time
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
import dateutil
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
@@ -15,6 +16,7 @@ from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
@@ -188,7 +190,7 @@ class Order(LoggedModel):
|
||||
def full_code(self):
|
||||
"""
|
||||
An order code which is unique among all events of a single organizer,
|
||||
built by contatenating the event slug and the order code.
|
||||
built by concatenating the event slug and the order code.
|
||||
"""
|
||||
return '{event}-{code}'.format(event=self.event.slug.upper(), code=self.code)
|
||||
|
||||
@@ -211,6 +213,12 @@ class Order(LoggedModel):
|
||||
def net_total(self):
|
||||
return self.total - self.tax_total
|
||||
|
||||
def cancel_allowed(self):
|
||||
return (
|
||||
self.status == Order.STATUS_PENDING
|
||||
or (self.status == Order.STATUS_PAID and self.total == Decimal('0.00'))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_code(code):
|
||||
tr = str.maketrans({
|
||||
@@ -270,7 +278,7 @@ class Order(LoggedModel):
|
||||
"""
|
||||
positions = self.positions.all().select_related('item')
|
||||
cancelable = all([op.item.allow_cancel for op in positions])
|
||||
return self.event.settings.cancel_allow_user and cancelable
|
||||
return self.cancel_allowed() and self.event.settings.cancel_allow_user and cancelable
|
||||
|
||||
@property
|
||||
def is_expired_by_time(self):
|
||||
@@ -498,6 +506,27 @@ class QuestionAnswer(models.Model):
|
||||
return str(_("No"))
|
||||
elif self.question.type == Question.TYPE_FILE:
|
||||
return str(_("<file>"))
|
||||
elif self.question.type == Question.TYPE_DATETIME and self.answer:
|
||||
try:
|
||||
d = dateutil.parser.parse(self.answer)
|
||||
if self.orderposition:
|
||||
tz = pytz.timezone(self.orderposition.order.event.settings.timezone)
|
||||
d = d.astimezone(tz)
|
||||
return date_format(d, "SHORT_DATETIME_FORMAT")
|
||||
except ValueError:
|
||||
return self.answer
|
||||
elif self.question.type == Question.TYPE_DATE and self.answer:
|
||||
try:
|
||||
d = dateutil.parser.parse(self.answer)
|
||||
return date_format(d, "SHORT_DATE_FORMAT")
|
||||
except ValueError:
|
||||
return self.answer
|
||||
elif self.question.type == Question.TYPE_TIME and self.answer:
|
||||
try:
|
||||
d = dateutil.parser.parse(self.answer)
|
||||
return date_format(d, "TIME_FORMAT")
|
||||
except ValueError:
|
||||
return self.answer
|
||||
else:
|
||||
return self.answer
|
||||
|
||||
@@ -519,7 +548,7 @@ class AbstractPosition(models.Model):
|
||||
:type variation: ItemVariation
|
||||
:param datetime: The datetime this item was put into the cart
|
||||
:type datetime: datetime
|
||||
:param expires: The date until this item is guarenteed to be reserved
|
||||
:param expires: The date until this item is guaranteed to be reserved
|
||||
:type expires: datetime
|
||||
:param price: The price of this item
|
||||
:type price: decimal.Decimal
|
||||
@@ -585,7 +614,7 @@ class AbstractPosition(models.Model):
|
||||
else:
|
||||
return {}
|
||||
|
||||
def cache_answers(self):
|
||||
def cache_answers(self, all=True):
|
||||
"""
|
||||
Creates two properties on the object.
|
||||
(1) answ: a dictionary of question.id → answer string
|
||||
@@ -598,7 +627,13 @@ class AbstractPosition(models.Model):
|
||||
# We need to clone our question objects, otherwise we will override the cached
|
||||
# answers of other items in the same cart if the question objects have been
|
||||
# selected via prefetch_related
|
||||
self.questions = list(copy.copy(q) for q in self.item.questions.all())
|
||||
if not all:
|
||||
if hasattr(self.item, 'questions_to_ask'):
|
||||
self.questions = list(copy.copy(q) for q in self.item.questions_to_ask)
|
||||
else:
|
||||
self.questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False))
|
||||
else:
|
||||
self.questions = list(copy.copy(q) for q in self.item.questions.all())
|
||||
for q in self.questions:
|
||||
if q.id in self.answ:
|
||||
q.answer = self.answ[q.id]
|
||||
@@ -769,6 +804,9 @@ class OrderPosition(AbstractPosition):
|
||||
'order_code': order.code
|
||||
})
|
||||
|
||||
# Delete afterwards. Deleting in between might cause deletion of things related to add-ons
|
||||
# due to the deletion cascade.
|
||||
for cartpos in cp:
|
||||
cartpos.delete()
|
||||
return ops
|
||||
|
||||
@@ -821,7 +859,7 @@ class CartPosition(AbstractPosition):
|
||||
the checkout process. This has all properties of AbstractPosition.
|
||||
|
||||
:param event: The event this belongs to
|
||||
:type event: Evnt
|
||||
:type event: Event
|
||||
:param cart_id: The user session that contains this cart position
|
||||
:type cart_id: str
|
||||
"""
|
||||
|
||||
@@ -87,7 +87,7 @@ class WaitingListEntry(LoggedModel):
|
||||
if self.variation
|
||||
else self.item.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache)
|
||||
)
|
||||
if availability[1] < 1:
|
||||
if availability[1] is None or availability[1] < 1:
|
||||
raise WaitingListException(_('This product is currently not available.'))
|
||||
if self.voucher:
|
||||
raise WaitingListException(_('A voucher has already been sent to this person.'))
|
||||
|
||||
233
src/pretix/base/notifications.py
Normal file
233
src/pretix/base/notifications.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import logging
|
||||
from collections import OrderedDict, namedtuple
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, LogEntry
|
||||
from pretix.base.signals import register_notification_types
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_ALL_TYPES = None
|
||||
|
||||
|
||||
NotificationAttribute = namedtuple('NotificationAttribute', ('title', 'value'))
|
||||
NotificationAction = namedtuple('NotificationAction', ('label', 'url'))
|
||||
|
||||
|
||||
class Notification:
|
||||
"""
|
||||
Represents a notification that is sent/shown to a user. A notification consists of:
|
||||
|
||||
* one ``event`` reference
|
||||
* one ``title`` text that is shown e.g. in the email subject or in a headline
|
||||
* optionally one ``detail`` text that may or may not be shown depending on the notification method
|
||||
* optionally one ``url`` that should be absolute and point to the context of an notification (e.g. an order)
|
||||
* optionally a number of attributes consisting of a title and a value that can be used to add additional details
|
||||
to the notification (e.g. "Customer: ABC")
|
||||
* optionally a number of actions that may or may not be shown as buttons depending on the notification method,
|
||||
each consisting of a button label and an absolute URL to point to.
|
||||
"""
|
||||
|
||||
def __init__(self, event: Event, title: str, detail: str=None, url: str=None):
|
||||
self.title = title
|
||||
self.event = event
|
||||
self.detail = detail
|
||||
self.url = url
|
||||
self.attributes = []
|
||||
self.actions = []
|
||||
|
||||
def add_action(self, label, url):
|
||||
"""
|
||||
Add an action to the notification, defined by a label and an url. An example could be a label of "View order"
|
||||
and an url linking to the order detail page.
|
||||
"""
|
||||
self.actions.append(NotificationAction(label, url))
|
||||
|
||||
def add_attribute(self, title, value):
|
||||
"""
|
||||
Add an attribute to the notification, defined by a title and a value. An example could be a title of
|
||||
"Date" and a value of "2017-12-14".
|
||||
"""
|
||||
self.attributes.append(NotificationAttribute(title, value))
|
||||
|
||||
|
||||
class NotificationType:
|
||||
def __init__(self, event: Event = None):
|
||||
self.event = event
|
||||
|
||||
def __repr__(self):
|
||||
return '<NotificationType: {}>'.format(self.action_type)
|
||||
|
||||
@property
|
||||
def action_type(self) -> str:
|
||||
"""
|
||||
The action_type string that this notification handles, for example
|
||||
``"pretix.event.order.paid"``. Only one notification type should be registered
|
||||
per action type.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@property
|
||||
def verbose_name(self) -> str:
|
||||
"""
|
||||
A human-readable name of this notification type.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@property
|
||||
def required_permission(self) -> str:
|
||||
"""
|
||||
The permission a user needs to hold for the related event to receive this
|
||||
notification.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
def build_notification(self, logentry: LogEntry) -> Notification:
|
||||
"""
|
||||
This is the main function that you should override. It is supposed to turn a log entry
|
||||
object into a notification object that can then be rendered e.g. into an email.
|
||||
"""
|
||||
return Notification(
|
||||
logentry.event,
|
||||
logentry.display()
|
||||
)
|
||||
|
||||
|
||||
def get_all_notification_types(event=None):
|
||||
global _ALL_TYPES
|
||||
|
||||
if event is None and _ALL_TYPES:
|
||||
return _ALL_TYPES
|
||||
|
||||
types = OrderedDict()
|
||||
for recv, ret in register_notification_types.send(event):
|
||||
if isinstance(ret, (list, tuple)):
|
||||
for r in ret:
|
||||
types[r.action_type] = r
|
||||
else:
|
||||
types[ret.action_type] = ret
|
||||
if event is None:
|
||||
_ALL_TYPES = types
|
||||
return types
|
||||
|
||||
|
||||
class ActionRequiredNotificationType(NotificationType):
|
||||
required_permission = "can_change_orders"
|
||||
action_type = "pretix.event.action_required"
|
||||
verbose_name = _("Administrative action required")
|
||||
|
||||
def build_notification(self, logentry: LogEntry):
|
||||
control_url = build_absolute_uri(
|
||||
'control:event.requiredactions',
|
||||
kwargs={
|
||||
'organizer': logentry.event.organizer.slug,
|
||||
'event': logentry.event.slug,
|
||||
}
|
||||
)
|
||||
|
||||
n = Notification(
|
||||
event=logentry.event,
|
||||
title=_('Administrative action required'),
|
||||
detail=_('Something happened in your event that our system cannot handle automatically, e.g. an external '
|
||||
'refund. You need to resolve it manually or choose to ignore it, depending on the issue at hand.'),
|
||||
url=control_url
|
||||
)
|
||||
n.add_action(_('View all unresolved problems'), control_url)
|
||||
return n
|
||||
|
||||
|
||||
class ParametrizedOrderNotificationType(NotificationType):
|
||||
required_permission = "can_view_orders"
|
||||
|
||||
def __init__(self, event, action_type, verbose_name, title):
|
||||
self._action_type = action_type
|
||||
self._verbose_name = verbose_name
|
||||
self._title = title
|
||||
super().__init__(event)
|
||||
|
||||
@property
|
||||
def action_type(self):
|
||||
return self._action_type
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return self._verbose_name
|
||||
|
||||
def build_notification(self, logentry: LogEntry):
|
||||
order = logentry.content_object
|
||||
|
||||
order_url = build_absolute_uri(
|
||||
'control:event.order',
|
||||
kwargs={
|
||||
'organizer': logentry.event.organizer.slug,
|
||||
'event': logentry.event.slug,
|
||||
'code': order.code
|
||||
}
|
||||
)
|
||||
|
||||
n = Notification(
|
||||
event=logentry.event,
|
||||
title=self._title.format(order=order, event=logentry.event),
|
||||
url=order_url
|
||||
)
|
||||
n.add_attribute(_('Order code'), order.code)
|
||||
n.add_attribute(_('Order total'), '{} {}'.format(localize(order.total), logentry.event.currency))
|
||||
n.add_attribute(_('Order date'), date_format(order.datetime, 'SHORT_DATETIME_FORMAT'))
|
||||
n.add_attribute(_('Order status'), order.get_status_display())
|
||||
n.add_attribute(_('Order positions'), str(order.positions.count()))
|
||||
n.add_action(_('View order details'), order_url)
|
||||
return n
|
||||
|
||||
|
||||
@receiver(register_notification_types, dispatch_uid="base_register_default_notification_types")
|
||||
def register_default_notification_types(sender, **kwargs):
|
||||
return (
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.placed',
|
||||
_('New order placed'),
|
||||
_('A new order has been placed: {order.code}'),
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.paid',
|
||||
_('Order marked as paid'),
|
||||
_('Order {order.code} has been marked as paid.')
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.canceled',
|
||||
_('Order canceled'),
|
||||
_('Order {order.code} has been canceled.')
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.modified',
|
||||
_('Order information changed'),
|
||||
_('The ticket information of order {order.code} has been changed.')
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.contact.changed',
|
||||
_('Order contact address changed'),
|
||||
_('The contact address of order {order.code} has been changed.')
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.changed',
|
||||
_('Order changed'),
|
||||
_('Order {order.code} has been changed.')
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.refunded',
|
||||
_('Order refunded'),
|
||||
_('Order {order.code} has been refunded.')
|
||||
),
|
||||
ActionRequiredNotificationType(
|
||||
sender,
|
||||
)
|
||||
)
|
||||
@@ -11,7 +11,7 @@ from django.forms import Form
|
||||
from django.http import HttpRequest
|
||||
from django.template.loader import get_template
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
@@ -184,7 +184,9 @@ class BasePaymentProvider:
|
||||
('_invoice_text',
|
||||
I18nFormField(
|
||||
label=_('Text on invoices'),
|
||||
help_text=_('Will be printed just below the payment figures and above the closing text on invoices.'),
|
||||
help_text=_('Will be printed just below the payment figures and above the closing text on invoices. '
|
||||
'This will only be used if the invoice is generated before the order is paid. If the '
|
||||
'invoice is generated later, it will show a text stating that it has already been paid.'),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
widget_kwargs={'attrs': {'rows': '2'}}
|
||||
@@ -205,6 +207,8 @@ class BasePaymentProvider:
|
||||
The default implementation returns the content of the _invoice_text configuration
|
||||
variable (an I18nString), or an empty string if unconfigured.
|
||||
"""
|
||||
if order.status == Order.STATUS_PAID:
|
||||
return pgettext_lazy('invoice', 'The payment for this invoice has already been received.')
|
||||
return self.settings.get('_invoice_text', as_type=LazyI18nString, default='')
|
||||
|
||||
@property
|
||||
@@ -324,7 +328,7 @@ class BasePaymentProvider:
|
||||
at least store the user's input into his session.
|
||||
|
||||
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 behaviour
|
||||
if the input was valid and the frontend should continue with default behavior
|
||||
or a string containing a URL if the user should be redirected somewhere else.
|
||||
|
||||
On errors, you should use Django's message framework to display an error message
|
||||
@@ -375,7 +379,7 @@ class BasePaymentProvider:
|
||||
"""
|
||||
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.
|
||||
If you need any special behaviour, you can return a string
|
||||
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
|
||||
you should return the user to the order's detail page.
|
||||
|
||||
@@ -523,8 +527,8 @@ class BasePaymentProvider:
|
||||
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 behaviour or None to continue
|
||||
with default behaviour.
|
||||
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.
|
||||
@@ -588,8 +592,8 @@ class FreeOrderProvider(BasePaymentProvider):
|
||||
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 behaviour or None to continue
|
||||
with default behaviour.
|
||||
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.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from email.utils import formataddr
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
import bleach
|
||||
@@ -90,6 +91,10 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
body, body_md = render_mail(template, context)
|
||||
subject = str(subject).format_map(context)
|
||||
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
|
||||
if event:
|
||||
sender = formataddr((str(event.name), sender))
|
||||
else:
|
||||
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
|
||||
|
||||
subject = str(subject)
|
||||
body_plain = body
|
||||
|
||||
104
src/pretix/base/services/notifications.py
Normal file
104
src/pretix/base/services/notifications.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from django.conf import settings
|
||||
from django.template.loader import get_template
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import LogEntry, NotificationSetting, User
|
||||
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.celery_app import app
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
|
||||
@app.task(base=TransactionAwareTask)
|
||||
def notify(logentry_id: int):
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
if not logentry.event:
|
||||
return # Ignore, we only have event-related notifications right now
|
||||
types = get_all_notification_types(logentry.event)
|
||||
notification_type = types.get(logentry.action_type)
|
||||
if not notification_type:
|
||||
return # Ignore, e.g. plugin not active for this event
|
||||
|
||||
# All users that have the permission to get the notification
|
||||
users = logentry.event.get_users_with_permission(
|
||||
notification_type.required_permission
|
||||
).filter(notifications_send=True)
|
||||
if logentry.user:
|
||||
users = users.exclude(pk=logentry.user.pk)
|
||||
|
||||
# Get all notification settings, both specific to this event as well as global
|
||||
notify_specific = {
|
||||
(ns.user, ns.method): ns.enabled
|
||||
for ns in NotificationSetting.objects.filter(
|
||||
event=logentry.event,
|
||||
action_type=logentry.action_type,
|
||||
user__pk__in=users.values_list('pk', flat=True)
|
||||
)
|
||||
}
|
||||
notify_global = {
|
||||
(ns.user, ns.method): ns.enabled
|
||||
for ns in NotificationSetting.objects.filter(
|
||||
action_type=logentry.action_type,
|
||||
user__pk__in=users.values_list('pk', flat=True)
|
||||
)
|
||||
}
|
||||
|
||||
for um, enabled in notify_specific.items():
|
||||
user, method = um
|
||||
if enabled:
|
||||
send_notification.apply_async(args=(logentry_id, user.pk, method))
|
||||
|
||||
for um, enabled in notify_global.items():
|
||||
user, method = um
|
||||
if enabled and um not in notify_specific:
|
||||
send_notification.apply_async(args=(logentry_id, user.pk, method))
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def send_notification(logentry_id: int, user_id: int, method: str):
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
types = get_all_notification_types(logentry.event)
|
||||
notification_type = types.get(logentry.action_type)
|
||||
if not notification_type:
|
||||
return # Ignore, e.g. plugin not active for this event
|
||||
|
||||
with language(user.locale):
|
||||
notification = notification_type.build_notification(logentry)
|
||||
|
||||
if method == "mail":
|
||||
send_notification_mail(notification, user)
|
||||
|
||||
|
||||
def send_notification_mail(notification: Notification, user: User):
|
||||
ctx = {
|
||||
'site': settings.PRETIX_INSTANCE_NAME,
|
||||
'site_url': settings.SITE_URL,
|
||||
'color': '#8E44B3',
|
||||
'notification': notification,
|
||||
'settings_url': build_absolute_uri(
|
||||
'control:user.settings.notifications',
|
||||
),
|
||||
'disable_url': build_absolute_uri(
|
||||
'control:user.settings.notifications.off',
|
||||
kwargs={
|
||||
'token': user.notifications_token,
|
||||
'id': user.pk
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
tpl_html = get_template('pretixbase/email/notification.html')
|
||||
body_html = tpl_html.render(ctx)
|
||||
tpl_plain = get_template('pretixbase/email/notification.txt')
|
||||
body_plain = tpl_plain.render(ctx)
|
||||
|
||||
mail_send_task.apply_async(kwargs={
|
||||
'to': [user.email],
|
||||
'subject': '[{}] {}'.format(settings.PRETIX_INSTANCE_NAME, notification.title),
|
||||
'body': body_plain,
|
||||
'html': body_html,
|
||||
'sender': settings.MAIL_FROM,
|
||||
'headers': {},
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import pytz
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Max, Q
|
||||
from django.db.models import F, Max, Q, Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import make_aware, now
|
||||
@@ -25,8 +25,8 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.orders import (
|
||||
CachedTicket, InvoiceAddress, OrderFee, generate_position_secret,
|
||||
generate_secret,
|
||||
CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee,
|
||||
generate_position_secret, generate_secret,
|
||||
)
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
@@ -128,8 +128,14 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
|
||||
order_paid.send(order.event, order=order)
|
||||
|
||||
invoice = None
|
||||
if order.event.settings.get('invoice_generate') in ('True', 'paid') and invoice_qualified(order):
|
||||
if not order.invoices.exists():
|
||||
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
|
||||
@@ -231,6 +237,32 @@ def mark_order_refunded(order, user=None):
|
||||
return order
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def mark_order_expired(order, user=None, api_token=None):
|
||||
"""
|
||||
Mark this order as expired. This sets the payment status and returns the order object.
|
||||
:param order: The order to change
|
||||
:param user: The user that performed the change
|
||||
:param api_token: The API token used to performed the change
|
||||
"""
|
||||
if isinstance(order, int):
|
||||
order = Order.objects.get(pk=order)
|
||||
if isinstance(user, int):
|
||||
user = User.objects.get(pk=user)
|
||||
if isinstance(api_token, int):
|
||||
api_token = TeamAPIToken.objects.get(pk=api_token)
|
||||
with order.event.lock():
|
||||
order.status = Order.STATUS_EXPIRED
|
||||
order.save()
|
||||
|
||||
order.log_action('pretix.event.order.expired', user=user, api_token=api_token)
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i:
|
||||
generate_cancellation(i)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None):
|
||||
"""
|
||||
@@ -245,7 +277,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None):
|
||||
if isinstance(api_token, int):
|
||||
api_token = TeamAPIToken.objects.get(pk=api_token)
|
||||
with order.event.lock():
|
||||
if order.status != Order.STATUS_PENDING:
|
||||
if not order.cancel_allowed():
|
||||
raise OrderError(_('You cannot cancel this order.'))
|
||||
order.status = Order.STATUS_CANCELED
|
||||
order.save()
|
||||
@@ -513,7 +545,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
invoice = generate_invoice(order, trigger_pdf=not event.settings.invoice_email_attachment)
|
||||
# send_mail will trigger PDF generation later
|
||||
|
||||
if order.total == Decimal('0.00'):
|
||||
if order.payment_provider == 'free':
|
||||
email_template = event.settings.mail_text_order_free
|
||||
log_entry = 'pretix.event.order.email.order_free'
|
||||
else:
|
||||
@@ -562,9 +594,7 @@ def expire_orders(sender, **kwargs):
|
||||
expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)
|
||||
eventcache[o.event.pk] = expire
|
||||
if expire:
|
||||
o.status = Order.STATUS_EXPIRED
|
||||
o.log_action('pretix.event.order.expired')
|
||||
o.save()
|
||||
mark_order_expired(o)
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@@ -646,7 +676,7 @@ def send_download_reminders(sender, **kwargs):
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.expire_warning_sent'
|
||||
'pretix.event.order.email.download_reminder_sent'
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
@@ -680,6 +710,7 @@ class OrderChangeManager:
|
||||
self.order = order
|
||||
self.user = user
|
||||
self.split_order = None
|
||||
self._committed = False
|
||||
self._totaldiff = 0
|
||||
self._quotadiff = Counter()
|
||||
self._operations = []
|
||||
@@ -824,7 +855,10 @@ class OrderChangeManager:
|
||||
raise OrderError(self.error_messages['paid_price_change'])
|
||||
|
||||
def _check_paid_to_free(self):
|
||||
if self.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
|
||||
# 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)
|
||||
try:
|
||||
mark_order_paid(
|
||||
self.order, 'free', send_mail=False, count_waitinglist=False,
|
||||
@@ -1015,6 +1049,16 @@ class OrderChangeManager:
|
||||
self.order.total += sum([f.value for f in self.order.fees.all()])
|
||||
self.order.save()
|
||||
|
||||
def _payment_fee_diff(self):
|
||||
prov = self._get_payment_provider()
|
||||
if self.order.status != Order.STATUS_PAID and prov:
|
||||
# payment fees of paid orders do not change
|
||||
old_fee = OrderFee.objects.filter(order=self.order, fee_type=OrderFee.FEE_TYPE_PAYMENT).aggregate(s=Sum('value'))['s'] or 0
|
||||
new_total = sum([p.price for p in self.order.positions.all()]) + self._totaldiff
|
||||
if new_total != 0:
|
||||
new_fee = prov.calculate_fee(new_total)
|
||||
self._totaldiff += new_fee - old_fee
|
||||
|
||||
def _reissue_invoice(self):
|
||||
i = self.order.invoices.filter(is_cancellation=False).last()
|
||||
if i and self._invoice_dirty:
|
||||
@@ -1062,9 +1106,18 @@ class OrderChangeManager:
|
||||
logger.exception('Order changed email could not be sent')
|
||||
|
||||
def commit(self):
|
||||
if self._committed:
|
||||
# an order change can only be committed once
|
||||
raise OrderError(error_messages['internal'])
|
||||
self._committed = True
|
||||
|
||||
if not self._operations:
|
||||
# Do nothing
|
||||
return
|
||||
|
||||
# finally, incorporate difference in payment fees
|
||||
self._payment_fee_diff()
|
||||
|
||||
with transaction.atomic():
|
||||
with self.order.event.lock():
|
||||
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
@@ -1078,6 +1131,7 @@ class OrderChangeManager:
|
||||
self._reissue_invoice()
|
||||
self._clear_tickets_cache()
|
||||
self._check_paid_to_free()
|
||||
|
||||
if self.notify:
|
||||
self._notify_user(self.order)
|
||||
if self.split_order:
|
||||
@@ -1085,6 +1139,10 @@ class OrderChangeManager:
|
||||
|
||||
def _clear_tickets_cache(self):
|
||||
CachedTicket.objects.filter(order_position__order=self.order).delete()
|
||||
CachedCombinedTicket.objects.filter(order=self.order).delete()
|
||||
if self.split_order:
|
||||
CachedTicket.objects.filter(order_position__order=self.split_order).delete()
|
||||
CachedCombinedTicket.objects.filter(order=self.split_order).delete()
|
||||
|
||||
def _get_payment_provider(self):
|
||||
pprov = self.order.event.get_payment_providers().get(self.order.payment_provider)
|
||||
@@ -1103,7 +1161,7 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
return OrderError(error_messages['busy'])
|
||||
raise OrderError(str(error_messages['busy']))
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
@@ -1112,6 +1170,6 @@ def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_tok
|
||||
try:
|
||||
return _cancel_order(order, user, send_mail, api_token)
|
||||
except LockTimeoutException:
|
||||
self.retry(exc=OrderError(error_messages['busy']))
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
return OrderError(error_messages['busy'])
|
||||
raise OrderError(error_messages['busy'])
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import sys
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.models import Event, User, WaitingListEntry
|
||||
@@ -41,7 +43,7 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
|
||||
if wle.variation
|
||||
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache, subevent=wle.subevent)
|
||||
)
|
||||
if availability[1] > 0:
|
||||
if availability[1] is None or availability[1] > 0:
|
||||
try:
|
||||
wle.send_voucher(quota_cache, user=user)
|
||||
sent += 1
|
||||
@@ -52,7 +54,7 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
|
||||
for q in quotas:
|
||||
quota_cache[q.pk] = (
|
||||
quota_cache[q.pk][0] if quota_cache[q.pk][0] > 1 else 0,
|
||||
quota_cache[q.pk][1] - 1
|
||||
quota_cache[q.pk][1] - 1 if quota_cache[q.pk][1] is not None else sys.maxsize
|
||||
)
|
||||
else:
|
||||
gone.add((wle.item, wle.variation))
|
||||
|
||||
@@ -26,6 +26,10 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
"""
|
||||
|
||||
def _is_active(self, sender, receiver):
|
||||
if sender is None:
|
||||
# Send to all events!
|
||||
return True
|
||||
|
||||
# Find the Django application this belongs to
|
||||
searchpath = receiver.__module__
|
||||
core_module = any([searchpath.startswith(cm) for cm in settings.CORE_MODULES])
|
||||
@@ -140,6 +144,19 @@ subclass of pretix.base.ticketoutput.BaseTicketOutput
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
register_notification_types = EventPluginSignal(
|
||||
providing_args=[]
|
||||
)
|
||||
"""
|
||||
This signal is sent out to get all known notification types. Receivers should return an
|
||||
instance of a subclass of pretix.base.notifications.NotificationType or a list of such
|
||||
instances.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event,
|
||||
however for this signal, the ``sender`` **may also be None** to allow creating the general
|
||||
notification settings!
|
||||
"""
|
||||
|
||||
register_data_exporters = EventPluginSignal(
|
||||
providing_args=[]
|
||||
)
|
||||
@@ -315,7 +332,7 @@ This signal allows you to implement a middleware-style filter on all outgoing em
|
||||
return a (possibly modified) copy of the message object passed to you.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
The ``message`` argument will contian an ``EmailMultiAlternatives`` object.
|
||||
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
||||
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
||||
it will be ``None``.
|
||||
"""
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixbase/scss/error.scss" %}" />
|
||||
{% endcompress %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8">
|
||||
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
167
src/pretix/base/templates/pretixbase/email/base.html
Normal file
167
src/pretix/base/templates/pretixbase/email/base.html
Normal file
@@ -0,0 +1,167 @@
|
||||
{% load eventurl %}
|
||||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=false">
|
||||
</head>
|
||||
<style type="text/css">
|
||||
body {
|
||||
background-color: #e8e8e8;
|
||||
background-position: top;
|
||||
background-repeat: repeat-x;
|
||||
font-family: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
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;
|
||||
padding: 12px 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
td.containertd {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #cccccc;
|
||||
}
|
||||
|
||||
{% block addcss %}{% endblock %}
|
||||
</style>
|
||||
<body>
|
||||
<table class="layout">
|
||||
<tr>
|
||||
<td class="header" background="">
|
||||
{% if event %}
|
||||
<h1><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
|
||||
{% else %}
|
||||
<h1><a href="{{ site_url }}" target="_blank">{{ site }}</a></h1>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
<tr>
|
||||
<td class="footer">
|
||||
<div>
|
||||
{% include "pretixbase/email/email_footer.html" %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<br/>
|
||||
<br/>
|
||||
</body>
|
||||
</html>
|
||||
53
src/pretix/base/templates/pretixbase/email/notification.html
Normal file
53
src/pretix/base/templates/pretixbase/email/notification.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "pretixbase/email/base.html" %}
|
||||
{% load eventurl %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
<tr>
|
||||
<td class="containertd">
|
||||
<div class="content">
|
||||
<h3>
|
||||
{% if notification.url %}<a href="{{ notification.url }}">{% endif %}
|
||||
{{ notification.title }}
|
||||
{% if notification.url %}</a>{% endif %}
|
||||
</h3>
|
||||
{% if notification.detail %}
|
||||
<p>{{ notification.detail }}</p>
|
||||
{% endif %}
|
||||
{% if notification.attributes %}
|
||||
<table>
|
||||
{% for attr in notification.attributes %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ attr.title }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{{ attr.value }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
{% if notification.actions %}
|
||||
<p class="actions" style="text-align: center">
|
||||
{% for action in notification.actions %}
|
||||
<a href="{{ action.url }}" class="button">{{ action.label }}</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="containertd">
|
||||
<div class="content">
|
||||
{% trans "You receive these emails based on your notification settings." %}<br>
|
||||
<a href="{{ settings_url }}">
|
||||
{% trans "Click here to view and change your notification settings" %}
|
||||
</a><br>
|
||||
<a href="{{ disable_url }}">
|
||||
{% trans "Click here disable all notifications immediately." %}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endblock %}
|
||||
18
src/pretix/base/templates/pretixbase/email/notification.txt
Normal file
18
src/pretix/base/templates/pretixbase/email/notification.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
{% load i18n %}
|
||||
{{ notification.title }}{% if notification.detail %}
|
||||
|
||||
{{ notification.detail }}
|
||||
{% endif %}{% if notification.url %}
|
||||
|
||||
{{ notification.url }}{% endif %}{% for attr in notification.attributes %}
|
||||
|
||||
{{ attr.title }}: {{ attr.value }}{% endfor %}{% for action in notification.actions %}
|
||||
|
||||
{{ action.label }}
|
||||
{{ action.url }}{% endfor %}
|
||||
|
||||
{% trans "You receive these emails based on your notification settings." %}
|
||||
{% trans "Click here to view and change your notification settings:" %}
|
||||
{{ settings_url }}
|
||||
{% trans "Click here disable all notifications immediately:" %}
|
||||
{{ disable_url }}
|
||||
@@ -1,174 +1,42 @@
|
||||
{% extends "pretixbase/email/base.html" %}
|
||||
{% load eventurl %}
|
||||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=false">
|
||||
</head>
|
||||
<style type="text/css">
|
||||
body {
|
||||
background-color: #e8e8e8;
|
||||
background-position: top;
|
||||
background-repeat: repeat-x;
|
||||
font-family: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
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 {
|
||||
width: 90%;
|
||||
max-width: 900px;
|
||||
border-spacing: 0px;
|
||||
border-collapse: separate;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
@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;
|
||||
padding: 12px 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
td.containertd {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #cccccc;
|
||||
}
|
||||
|
||||
{% block addcss %}{% endblock %}
|
||||
</style>
|
||||
<body>
|
||||
<table>
|
||||
{% block content %}
|
||||
<tr>
|
||||
<td class="containertd">
|
||||
<div class="content">
|
||||
{{ body|safe }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% if order %}
|
||||
<tr>
|
||||
<td class="header" background="">
|
||||
{% if event %}
|
||||
<h1><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
|
||||
{% else %}
|
||||
<h1><a href="{{ site_url }}" target="_blank">{{ site }}</a></h1>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="gap"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="containertd">
|
||||
<td class="order containertd">
|
||||
<div class="content">
|
||||
{{ body|safe }}
|
||||
{% 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 "Order code:" %}</strong> {{ order.code }}<br>
|
||||
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
|
||||
<a href="{% abseventurl event "presale:event.order" order=order.code secret=order.secret %}">
|
||||
{% trans "View order details" %}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% if order %}
|
||||
<tr>
|
||||
<td class="gap"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="order containertd">
|
||||
<div class="content">
|
||||
{% 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 "Order code:" %}</strong> {{ order.code }}<br>
|
||||
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
|
||||
<a href="{% abseventurl event "presale:event.order" order=order.code secret=order.secret %}">
|
||||
{% trans "View order details" %}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if signature %}
|
||||
<tr>
|
||||
<td class="gap"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="order containertd">
|
||||
<div class="content">
|
||||
{{ signature | safe }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td class="footer">
|
||||
<div>
|
||||
{% include "pretixbase/email/email_footer.html" %}
|
||||
<td class="gap"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="order containertd">
|
||||
<div class="content">
|
||||
{{ signature | safe }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<br/>
|
||||
<br/>
|
||||
</body>
|
||||
</html>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="splitdatetimerow">
|
||||
{% include 'django/forms/widgets/multiwidget.html' %}
|
||||
</div>
|
||||
@@ -42,6 +42,7 @@ ALLOWED_TAGS = [
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'pre',
|
||||
# Update doc/user/markdown.rst if you change this!
|
||||
]
|
||||
|
||||
|
||||
194
src/pretix/base/views/mixins.py
Normal file
194
src/pretix/base/views/mixins.py
Normal file
@@ -0,0 +1,194 @@
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db.models import Prefetch
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from pretix.base.forms.questions import (
|
||||
BaseInvoiceAddressForm, BaseQuestionsForm,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
|
||||
QuestionOption,
|
||||
)
|
||||
|
||||
|
||||
class BaseQuestionsViewMixin:
|
||||
form_class = BaseQuestionsForm
|
||||
|
||||
@staticmethod
|
||||
def _keyfunc(pos):
|
||||
# Sort addons after the item they are an addon to
|
||||
if isinstance(pos, OrderPosition):
|
||||
i = pos.addon_to.positionid if pos.addon_to else pos.positionid
|
||||
else:
|
||||
i = pos.addon_to.pk if pos.addon_to else pos.pk
|
||||
addon_penalty = 1 if pos.addon_to else 0
|
||||
return i, addon_penalty, pos.pk
|
||||
|
||||
@cached_property
|
||||
def _positions_for_questions(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@cached_property
|
||||
def forms(self):
|
||||
"""
|
||||
A list of forms with one form for each cart position that has questions
|
||||
the user can answer. All forms have a custom prefix, so that they can all be
|
||||
submitted at once.
|
||||
"""
|
||||
formlist = []
|
||||
for cr in self._positions_for_questions:
|
||||
cartpos = cr if isinstance(cr, CartPosition) else None
|
||||
orderpos = cr if isinstance(cr, OrderPosition) else None
|
||||
form = self.form_class(event=self.request.event,
|
||||
prefix=cr.id,
|
||||
cartpos=cartpos,
|
||||
orderpos=orderpos,
|
||||
data=(self.request.POST if self.request.method == 'POST' else None),
|
||||
files=(self.request.FILES if self.request.method == 'POST' else None))
|
||||
form.pos = cartpos or orderpos
|
||||
if len(form.fields) > 0:
|
||||
formlist.append(form)
|
||||
return formlist
|
||||
|
||||
@cached_property
|
||||
def formdict(self):
|
||||
storage = OrderedDict()
|
||||
for f in self.forms:
|
||||
pos = f.cartpos or f.orderpos
|
||||
if pos.addon_to_id:
|
||||
if pos.addon_to not in storage:
|
||||
storage[pos.addon_to] = []
|
||||
storage[pos.addon_to].append(f)
|
||||
else:
|
||||
if pos not in storage:
|
||||
storage[pos] = []
|
||||
storage[pos].append(f)
|
||||
return storage
|
||||
|
||||
def save(self):
|
||||
failed = False
|
||||
for form in self.forms:
|
||||
meta_info = form.pos.meta_info_data
|
||||
# Every form represents a CartPosition or OrderPosition with questions attached
|
||||
if not form.is_valid():
|
||||
failed = True
|
||||
else:
|
||||
# This form was correctly filled, so we store the data as
|
||||
# answers to the questions / in the CartPosition object
|
||||
for k, v in form.cleaned_data.items():
|
||||
if k == 'attendee_name':
|
||||
form.pos.attendee_name = v if v != '' else None
|
||||
form.pos.save()
|
||||
elif k == 'attendee_email':
|
||||
form.pos.attendee_email = v if v != '' else None
|
||||
form.pos.save()
|
||||
elif k.startswith('question_') and v is not None:
|
||||
field = form.fields[k]
|
||||
if hasattr(field, 'answer'):
|
||||
# We already have a cached answer object, so we don't
|
||||
# have to create a new one
|
||||
if v == '' or v is None or (isinstance(field, forms.FileField) and v is False):
|
||||
if field.answer.file:
|
||||
field.answer.file.delete()
|
||||
field.answer.delete()
|
||||
else:
|
||||
self._save_to_answer(field, field.answer, v)
|
||||
field.answer.save()
|
||||
elif v != '':
|
||||
answer = QuestionAnswer(
|
||||
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
|
||||
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
|
||||
question=field.question,
|
||||
)
|
||||
self._save_to_answer(field, answer, v)
|
||||
answer.save()
|
||||
else:
|
||||
meta_info.setdefault('question_form_data', {})
|
||||
if v is None:
|
||||
if k in meta_info['question_form_data']:
|
||||
del meta_info['question_form_data'][k]
|
||||
else:
|
||||
meta_info['question_form_data'][k] = v
|
||||
|
||||
form.pos.meta_info = json.dumps(meta_info)
|
||||
form.pos.save(update_fields=['meta_info'])
|
||||
return not failed
|
||||
|
||||
def _save_to_answer(self, field, answer, value):
|
||||
if isinstance(field, forms.ModelMultipleChoiceField):
|
||||
answstr = ", ".join([str(o) for o in value])
|
||||
if not answer.pk:
|
||||
answer.save()
|
||||
else:
|
||||
answer.options.clear()
|
||||
answer.answer = answstr
|
||||
answer.options.add(*value)
|
||||
elif isinstance(field, forms.ModelChoiceField):
|
||||
if not answer.pk:
|
||||
answer.save()
|
||||
else:
|
||||
answer.options.clear()
|
||||
answer.options.add(value)
|
||||
answer.answer = value.answer
|
||||
elif isinstance(field, forms.FileField):
|
||||
if isinstance(value, UploadedFile):
|
||||
answer.file.save(value.name, value)
|
||||
answer.answer = 'file://' + value.name
|
||||
else:
|
||||
answer.answer = value
|
||||
|
||||
|
||||
class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
invoice_form_class = BaseInvoiceAddressForm
|
||||
|
||||
@cached_property
|
||||
def _positions_for_questions(self):
|
||||
return self.positions
|
||||
|
||||
@cached_property
|
||||
def positions(self):
|
||||
return list(self.order.positions.select_related(
|
||||
'item', 'variation'
|
||||
).prefetch_related(
|
||||
Prefetch('answers',
|
||||
QuestionAnswer.objects.prefetch_related('options'),
|
||||
to_attr='answerlist'),
|
||||
Prefetch('item__questions',
|
||||
Question.objects.filter(ask_during_checkin=False).prefetch_related(
|
||||
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
|
||||
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
|
||||
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
|
||||
# a prefetch lookup on this query...
|
||||
'question',
|
||||
Question.objects.none(),
|
||||
to_attr='dummy'
|
||||
)))
|
||||
),
|
||||
to_attr='questions_to_ask')
|
||||
))
|
||||
|
||||
@cached_property
|
||||
def invoice_address(self):
|
||||
try:
|
||||
return self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
return InvoiceAddress(order=self.order)
|
||||
|
||||
@cached_property
|
||||
def invoice_form(self):
|
||||
return self.invoice_form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
event=self.request.event,
|
||||
instance=self.invoice_address, validate_vat_id=False
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['order'] = self.order
|
||||
ctx['formgroups'] = self.formdict.items()
|
||||
ctx['invoice_form'] = self.invoice_form
|
||||
return ctx
|
||||
@@ -3,11 +3,12 @@ from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import Resolver404, get_script_prefix, resolve
|
||||
from django.utils.translation import get_language
|
||||
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
|
||||
from ..helpers.i18n import get_javascript_format, get_moment_locale
|
||||
from .signals import html_head, nav_event, nav_global, nav_topbar
|
||||
from .utils.i18n import get_javascript_format, get_moment_locale
|
||||
|
||||
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
||||
|
||||
@@ -57,6 +58,10 @@ def contextprocessor(request):
|
||||
ctx['new_session'] = child_sess
|
||||
request.session['event_access'] = True
|
||||
|
||||
if request.GET.get('subevent', ''):
|
||||
# Do not use .get() for lazy evaluation
|
||||
ctx['selected_subevents'] = request.event.subevents.filter(pk=request.GET.get('subevent'))
|
||||
|
||||
ctx['nav_event'] = _nav_event
|
||||
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
|
||||
|
||||
@@ -77,6 +82,7 @@ def contextprocessor(request):
|
||||
ctx['js_date_format'] = get_javascript_format('DATE_INPUT_FORMATS')
|
||||
ctx['js_time_format'] = get_javascript_format('TIME_INPUT_FORMATS')
|
||||
ctx['js_locale'] = get_moment_locale()
|
||||
ctx['select2locale'] = get_language()[:2]
|
||||
|
||||
if settings.DEBUG and 'runserver' not in sys.argv:
|
||||
ctx['debug_warning'] = True
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import os
|
||||
|
||||
from django import forms
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from ...base.forms import I18nModelForm
|
||||
# Import for backwards compatibility with okd import paths
|
||||
from ...base.forms.widgets import ( # noqa
|
||||
DatePickerWidget, SplitDateTimePickerWidget, TimePickerWidget,
|
||||
)
|
||||
|
||||
|
||||
class TolerantFormsetModelForm(I18nModelForm):
|
||||
@@ -100,34 +102,3 @@ class SlugWidget(forms.TextInput):
|
||||
ctx = super().get_context(name, value, attrs)
|
||||
ctx['pre'] = self.prefix
|
||||
return ctx
|
||||
|
||||
|
||||
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
|
||||
def __init__(self, attrs=None, date_format=None, time_format=None):
|
||||
attrs = attrs or {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
date_attrs = dict(attrs)
|
||||
time_attrs = dict(attrs)
|
||||
date_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
time_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
date_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
time_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
|
||||
widgets = (
|
||||
forms.DateInput(attrs=date_attrs, format=date_format),
|
||||
forms.TimeInput(attrs=time_attrs, format=time_format),
|
||||
)
|
||||
# Skip one hierarchy level
|
||||
forms.MultiWidget.__init__(self, widgets, attrs)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from django import forms
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
from pretix.base.models.checkin import CheckinList
|
||||
from pretix.control.forms.widgets import Select2
|
||||
|
||||
|
||||
class CheckinListForm(forms.ModelForm):
|
||||
@@ -11,6 +14,17 @@ class CheckinListForm(forms.ModelForm):
|
||||
self.fields['limit_products'].queryset = self.event.items.all()
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'Date')
|
||||
}
|
||||
)
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
self.fields['subevent'].required = True
|
||||
else:
|
||||
del self.fields['subevent']
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db.models import Q
|
||||
@@ -951,3 +952,43 @@ class WidgetCodeForm(forms.Form):
|
||||
raise ValidationError(_('The given voucher code does not exist.'))
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class EventDeleteForm(forms.Form):
|
||||
error_messages = {
|
||||
'pw_current_wrong': _("The password you entered was not correct."),
|
||||
'slug_wrong': _("The slug you entered was not correct."),
|
||||
}
|
||||
user_pw = forms.CharField(
|
||||
max_length=255,
|
||||
label=_("New password"),
|
||||
widget=forms.PasswordInput()
|
||||
)
|
||||
slug = forms.CharField(
|
||||
max_length=255,
|
||||
label=_("Event slug"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
self.user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_user_pw(self):
|
||||
user_pw = self.cleaned_data.get('user_pw')
|
||||
if not check_password(user_pw, self.user.password):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_current_wrong'],
|
||||
code='pw_current_wrong',
|
||||
)
|
||||
|
||||
return user_pw
|
||||
|
||||
def clean_slug(self):
|
||||
slug = self.cleaned_data.get('slug')
|
||||
if slug != self.event.slug:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['slug_wrong'],
|
||||
code='slug_wrong',
|
||||
)
|
||||
return slug
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user